Артем Нечипорук, Алексей Баринов

Сетевое программирование. От основ до приложений

Создатели книги

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

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

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

Авторы

Артем Нечипорук окончил Московский государственный университет экономики, статистики и информатики по специальности «Прикладная информатика в экономике».

Разработчик и архитектор программного обеспечения с более чем десятилетним опытом работы на Python и C++. Автор курса по сетевому программированию на C++.

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

Работал в компании «Тензор» над C++ ядром SaaS системы СБИС, используемой для онлайн-сдачи бухгалтерской отчетности по всей России.

С 2015 года работает в компаниях, связанных с информационной безопасностью. Занимался разработкой программно-аппаратных комплексов защищенной связи.

В настоящее время — архитектор программного обеспечения в отделе перспективных исследований «Лаборатории Касперского». Один из его проектов — «Kaspersky Neuromorphic Platform», платформа с открытым кодом для выполнения импульсных нейронных сетей.

Алексей Баринов — разработчик, свыше 25 лет проработавший в ИТ, более половины из них в области сетевой безопасности.

Начинал карьеру программистом контроллеров навигационных систем.

Затем в компании ООО «Газпромэнергодиагностика» проделал длинный путь от ведущего инженера до начальника отдела разработки ПО в области автоматизированного контроля газотранспортных объектов.

Далее был руководителем отдела системного ПО в сфере разработки устройств для цифрового телевидения в компании ЗАО «Рикор ИМТ».

С 2016 года работает в компаниях, занятых информационной безопасностью. Занимался разработкой программно-аппаратных комплексов защищенных сетевых решений.

В настоящее время — разработчик-аналитик в компании RDP.RU, специализирующейся на создании решений для защиты сетевого трафика.

Эксперты

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

Алексей Кузнецов в конце 1990-х и начале 2000-х годов являлся одним из ключевых разработчиков ядра Linux, занимая позицию мейнтейнера сетевой подсистемы с 2000 по 2003 год. За этот период Алексей существенно усовершенствовал сетевой стек Linux, адаптировав его для многоядерных систем, улучшив поддержку IPv6 и внедрив средства управления трафиком.

В дополнение к этому он переработал поддержку протокола TCP, что привело к созданию нового TCP-стека, который был представлен в версии ядра Linux 2.2 и до сих пор широко востребован. Среди разработанных Алексеем инструментов наиболее известны утилиты из набора iputils: ping, tracepath, tftpd, rarpd и утилиты для управления трафиком iproute2: ip, tc, ss.

Стивен Хеммингер (Stephen Hemminger) начал работать над TCP/IP и сетевыми драйверами для Unix с Tektronix, Sequent и nCube, а затем унаследовал разработку сети в Linux от Алексея Кузнецова. Более 20 лет в Linux Foundation он занимался ядром Linux, iproute2 и мостами. Оттуда он перешел в Vyatta, где работал главным инженером проекта программного маршрутизатора на базе Linux. Затем, чтобы внести свой вклад в сетевые драйверы Linux для Azure, он перешел на позицию архитектора в Microsoft.

В настоящее время он совместно с Дэвидом Ахерном занимается сопровождением iproute2.

Петр Савельев — автор пакета PyRoute2 и специалист по сокетам Netlink. Обладает более чем 20-летним опытом в индустрии телекома. Во время работы в Ericsson принимал участие в разработке сетевой подсистемы OpenStack, затем работал с сетями в Amazon Global Payments. На данный момент занимается решениями и аудитом сетевой безопасности в Kubernetes.

Владимир Строгов — эксперт по ядру Windows. Имеет свыше 30 лет опыта разработки на уровне ядра в области хранения данных и безопасности. Занимался файловыми системами, виртуализацией, защитой данных и антивирусной защитой, обратным инжинирингом.

Почти 10 лет Владимир работал над проектами Veritas и Symantec. Затем трудился в «Лаборатории Касперского», в группе Core Drivers, на позициях технического эксперта и руководителя группы. Сейчас Владимир является директором по развитию команды ядра в международной компании.

Никита Соболев — core-разработчик языка Python и его основного интерпретатора — CPython. На постоянной основе занимается разработкой различных open-source-технологий. Проводил ревью кода Python в книге.

Сергей Талантов — старший архитектор программного обеспечения в группе разработки шлюзов для интернета вещей на базе ОС ЛК. Проводил ревью кода C++. Контрибьютор в браузер Chromium.

Александр Певзнер — эксперт в области технологий сети, протоколов и сетевого оборудования. Проводил консультации по топологии сетей, работе сетевого оборудования, сетевым протоколам и их применению в современной практике. Старший системный инженер группы по управлению телекоммуникациями в «Лаборатории Касперского». Имеет большой опыт работы с сетями и автоматизацией управления сетевой инфраструктурой.

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

Консультанты

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

Руслан Морозов — руководитель отдела разработки решений для рабочих станций и мобильных устройств на базе ОС ЛК. Руслан обладает экспертизой во многих областях, рассмотренных в книге, в особенности в области отладки, безопасности и ядра Linux, а также в сложных вопросах, которые выходят за рамки первой книги.

Александр Гетьман — кандидат физико-математических наук. Занимается направлением анализа сетевого трафика в Институте системного программирования РАН. Александр консультировал авторов по сложным вопросам: например, проксирование, VPN и др. В книге использованы материалы некоторых его статей.

Андрей Федотов — кандидат технических наук, ранее старший научный сотрудник ИСП РАН, работает в компании Яндекс. Андрей предоставил консультации по работе систем статического анализа Svace и системы фаззинг-тестирования Sydr, членом команды разработки которых он являлся.

Святослав Размыслов — продуктовый менеджер PVS-Studio LLC. Он поделился знаниями о работе и возможностях статического анализатора кода PVS-Studio и помог составить описание анализатора PVS-Studio.

Ревьюверы

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

Сергей Талантов проверил и исправил код примеров на C++ — его коммиты доступны в репозитории книги.

Никита Соболев проверил и скорректировал весь Python-код из книги.

Андрей Маркобородов — старший разработчик «Лаборатории Касперского» в группе разработки решений для защиты сетей. Вычитал текст, диаграммы и код глав 3–12, 23, 24 и внес свои конструктивные замечания.

Владислав Егоров — специалист по направлению анализа сетевого трафика в ИСП РАН. Выполнил ревью глав 23 и 24.

Максим Дмитриченко — системный программист с более чем 20-летним опытом разработки. Пишет на C, C++ и Rust. Занимается разработкой встраиваемых систем, ЦОС, высокоскоростных систем анализа информации и принятия решений, обратной инженерией сетевых протоколов. Выполнил ревью глав 20–22, внес замечания по работе прокси. Провел ревью диаграммы сетевого стека Linux в главе 3, а также исследование по главе 6, выяснив, как избежать гонки при обработке сигнала.

Петр Савельев провел вычитку и исправления глав 11 и 12 по теме Netlink.

Александр Певзнер проверил все описания работы протоколов и особенности работы сети в главах 1–6 и 10–24.

Александр Гетьман провел ревью глав 21–23.

Владимир Строгов проверил диаграммы функционирования сетевого стека Windows в главе 3 и проверил главы 14–20 по ОС Windows.

Алексей Кузнецов проверил главу 8, по теме опций управления сокетами.

Руслан Морозов провел ревью глав 7–12.

Андрей Грес — инженер-разработчик встраиваемых систем на основе Linux, занимается конструированием и разработкой программного обеспечения для автомобильных систем экстренного реагирования при авариях и ДТП. Выполнил ревью всех глав, помог с написанием книги, доработал глоссарий, а также оказал помощь с проверкой кода примеров и исправлением примера с OOB.

Александр Цыганкин — технический руководитель кластера «Кибербезопасность» в компании MTS Digital, занимается созданием решений в сфере кибербезопасности и защиты инфраструктуры с использованием искусственного интеллекта. Провел ревью глав с 1-й по 4-ю. Исправил некоторые серьезные ошибки, в том числе в коде и терминологии.

Генрих Селеня — аспирант ОИПИ. Генрих пишет серверы на C++. Провел ревью глав 7 и 8.

Владимир Протасов — технический менеджер компании Яндекс. Провел ревью введения, а также глав 1 и 2.

Павел Балаев — старший разработчик Tarantool, ранее исследователь в отделе технологических исследований компании «Инфотекс». Провел ревью глав 1 и 2.

Иллюстратор

Автор всех иллюстраций в книге — Ксения Баринова.

Прочие участники

История Udev была написана с помощью участников русского сообщества Debian:

Алексей Петров — к.ф.-м. н., ФТИ им. Иоффе РАН.

Максим Дмитриченко — системный программист.

Илья Казакевич — участник русского сообщества Debian.

Предисловие

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

Новые требования к связи порождают необходимость создания эффективных сетевых решений. И навык сетевого программирования становится одним из ключевых для IT-специалистов.

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

Новички, желающие создавать сетевые приложения, думают, что это легко, — достаточно использовать сокеты: открыл соединение, передал данные, закрыл.

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

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

Эта книга стремится соединить теорию с практикой. Она служит инструментом для изучения сетевого программирования на уровне, достаточном для среднего разработчика в IT-компании.

Поскольку объем материала очень большой, мы разбили его на три книги. Первая книга — «Cетевое программирование. От основ до приложений», ее вы сейчас читаете. Книги «Сетевое программирование. Практика разработки приложений» (книга 2) и «Сетевое программирование. Безопасность и масштабируемые системы» (книга 3) готовятся к выходу в издательстве «Питер». Для удобства ссылки на них будут сделаны в главах.

Основой материала книги послужили лекции, написанные одним из авторов для онлайн-университета и доработанные по запросам студентов.

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

Примеры доступны в Git-репозитории по адресу https://github.com/artiomn/network-programming-book-code.

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

Там, где это необходимо для понимания и эффективного применения, мы разбираем структуру сетевых протоколов.

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

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

Главный язык, использованный в книгах, — C++. В качестве второго основного языка выбран Python. Иногда примеры приводятся на других языках.

Мы не стали брать за основу язык C, так как он слишком приближен к низкоуровневым языкам, а мы хотели показать, как использовать API в контексте платформ, на которых обычно реализуются современные приложения.

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

Вы научитесь работать с базовыми и продвинутыми сетевыми функциями для обеспечения коммуникации программ. Освоите решение задач связи и безопасности. Книга охватывает разработку на Python и C++, а также дает представление о сетевых механизмах в других языках.

Книгу проверял Никита Соболев — один из core-разработчиков CPython. Опции IP_RECVERR, IP_RECVTTL и IP_RECVORIGDSTADDR были добавлены в CPython 14 благодаря работе над книгой.

Чтобы успешно освоить материал книги, важно иметь хотя бы базовые знания и представления в области сетевых технологий: понимать назначение протоколов, знать, что такое TCP/IP и DNS, а также иметь навык работы на C++ выше начального уровня.

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

Если вы уже владеете материалом, содержащимся в каком-либо разделе, можете его пропустить. Например, начальные разделы посвящены сокетам, которые хорошо описаны в других источниках, таких как книга У. Стивенса, Б. Феннера, Э. Рудоффа «UNIX: разработка сетевых приложений», 3-е изд. (изд-во «Питер») или Beej’s Guide to Network Programming. Рекомендуем ознакомиться с этими ресурсами. Если вы без труда отвечаете на вопросы в конце очередной главы, можете двигаться дальше.

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

Условные обозначения

Так выделяется дополнительная техническая информация:

Тут будет техническая информация.

Места, на которые следует обратить внимание, выглядят так:

Внимание! Обратите внимание.

Текст вывода командной строки выделен серой заливкой:

➭ whatis socket

socket (7) — Linux socket interface

Исходный текст программы или просто код выглядит так:

#include <resolv.h>

int dn_comp(const char *exp_dn, unsigned char *comp_dn,

int length, unsigned char **dnptrs,

unsigned char **lastdnptr);

От издательства

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

Их рабочие названия «Сетевое программирование. Практика разработки приложений» и «Сетевое программирование. Безопасность и масштабируемые системы».

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

В книге «Сетевое программирование. От основ до приложений», которую вы сейчас читаете, в конце каждой главы приводятся вопросы и задачи для закрепления изученного материала. Ответы на вопросы вы можете получить, обратившись к авторам: Алексею Баринову по адресу aleks_bav@mail.ru или Артему Нечипоруку по адресу artiom14@yandex.ru.

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

Материал книги снабжен большим количеством ссылок на интернет-ресурсы, содержащие дополнительную информацию, полезные сведения, официальную документацию и т.п. В электронной версии книги все гиперссылки активны, и вы можете перейти по ним непосредственно из текста. В бумажной версии они обозначены нумерованными сносками и, чтобы не перегружать текст, вынесены в раздел «Список интернет-ресурсов» репозитория книги на GitHub.

Для перехода к репозиторию посетите https://github.com/artiomn/network-programming-book-code/.

Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).

Мы будем рады узнать ваше мнение!

На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.

Глава 1. Сетевое взаимодействие, сокеты, порядок байтов

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

Эндрю Таненбаум, «Компьютерные сети» (изд-во «Питер»)

Введение

Сперва ответим на вопрос, что же такое сетевое программирование.

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

Компьютерная сеть — это множество взаимодействующих и совместно использующих ресурсы вычислительных устройств. Сеть состоит из узлов и каналов, соединяющих узлы.

Такую среду можно представить в виде графа, изображенного на рис. 1.1, где узлы — это субъекты взаимодействия, а ребра — каналы обмена данными между узлами.

Субъектами взаимодействия могут быть:

• физические устройства;

• драйверы протоколов;

• приложения;

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

Каналы могут быть физическими, например проводной Ethernet или радиоканал, либо виртуальными, например канал OpenVPN, опирающийся на протоколы более высокого уровня, или разделяемые на маршрутизаторе по тегам — каналы VLAN.

В этой главе мы:

• разберем, что такое сетевое программирование, зачем оно нужно и какое место занимает в структуре знаний и навыков специалиста;

• узнаем, какие стандарты определяют интерфейс сокетов и работу протоколов;

• узнаем, где и как находить информацию;

• поймем, что такое сокеты, как их создавать, закрывать и назначать им адреса.

Рис. 1.1. Схема обмена данными по сети

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

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

В книге мы рассматриваем преимущественно разработку приложений.

Модели сетевого взаимодействия

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

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

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

Стек уровней в компьютерных сетях описывается такими моделями, как OSI — Open Systems Interconnection или DoD — Department of Defence — модель Министерства обороны США.

Модель OSI, показанная на рис. 1.2, — наиболее общая. Она состоит из семи уровней, каждый из которых обычно представлен одним протоколом либо его частью.

Рис. 1.2. Модель OSI

Уровни OSI сверху вниз:

• Прикладной — протоколы, которые используют конкретные приложения, например браузер: HTTP, FTP и т.п.

• Представления данных — кодировки ASCII, UNICODE, преобразования UTF, форматы данных, например JPEG или PNG, и т.п.

• Сеансовый — протоколы установления сессии, аутентификации, такие как L2TP, PAP/CHAP и т.п.

• Транспортный — обеспечивает передачу данных внутри сети: TCP, UDP, UDP Lite, SCTP и т.д.

• Сетевой — обеспечивает возможность обмена данными между разными сетями: IPv4, IPv6, устаревший — IPX.

• Канальный — протоколы обмена данными по каналу. Например Ethernet, определяющий формат кадров и правила согласования настроек адаптеров, PPP, выполняющий согласование параметров обмена, в частности PPPoE, работающий поверх Ethernet.

• Физический — различные физические каналы, спецификация типа среды, формы сигналов, напряжений, если это применимо к среде, например Ethernet по витой паре.

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

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

На практике удобнее работать с более простыми моделями, которых около десятка: модель Таненбаума, модель Cisco Academy и др. Одна из таких упрощенных моделей, показанная на рис. 1.3, — это DoD-модель, она же модель TCP/IP, которая содержит всего четыре уровня:

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

Рис. 1.3. DoD-модель

• Транспортный уровень — соответствует транспортному уровню OSI. Отвечает за гарантированную или негарантированную доставку и правильную последовательность прихода данных.

• Межсетевой — аналог сетевого уровня OSI. Нужен для передачи данных из одной сети в другую, независимо от протоколов нижнего уровня.

• Уровень сетевого доступа — объединяет физический и канальный уровни OSI. Описывает способ кодирования данных на физическом уровне и среду передачи, ее физические параметры.

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

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

Обмен данными по сети

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

Эти обертки в рамках модели называются служебными модулями данных — Service Data Unit, или SDU. Служебные потому, что передаются между уровнями внутри модели.

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

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

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

Рис. 1.4. Срез обмена данными по сети

Данные, которые циркулируют между приложениями, на каждом уровне передаются как единицы данных протокола, или Protocol Data Unit — PDU. Это могут быть пакеты TCP, дейтаграммы или IP-пакеты.

Существуют разные соглашения об именовании IP PDU:

• В MIL-STD-1777 и RFC-791 «Internet Protocol» — дейтаграмма.

• Популярное название в книгах — пакет.

В этой книге мы будем использовать популярное название «пакет».

Для PDU TCP будем использовать название «сегмент», а для UDP — «дейтаграмма».

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

Такая возможность позволяет строить нетривиальные схемы работы в сети. Например, VPN, работающий поверх HTTPS, позволяет обходить DLP-системы провайдеров, которые блокируют иные протоколы. Другой пример — разрабатываемый стандарт DoH, который подразумевает передачу DNS-запроса по обычному защищенному HTTPS-протоколу.

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

• время задержки от начала передачи;

• скорость передачи;

• возможность потерь и гарантии доставки;

• ограничение на маршрут передачи, особенно если данные конфиденциальные;

• срочность доставки.

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

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

• TCP — протокол, обеспечивающий гарантированную упорядоченную надежную передачу данных. Позволяет организовать поток из одного узла в другой. При его использовании отправитель точно знает, получил адресат данные или нет. Надежность в данном случае означает, что данные точно придут без искажений в содержании. Поток байтов означает, что отдельные вызовы отправки данных не создают границ и могут быть преобразованы во множество PDU. Этот протокол был описан и реализован в 1974 году.

• UDP — протокол без гарантий. На его основе пользователи могут реализовывать собственные протоколы. В рамках протокола UDP производится обмен сообщениями или дейтаграммами, каждый вызов отправки данных создает один PDU — дейтаграмму, которая содержит точно одну порцию данных.

• UDP Lite — протокол UDP с частичным расчетом контрольной суммы. Является расширением протокола UDP и используется для того, чтобы не выполнять лишние вычисления.

• SCTP — Stream Control Transmission Protocol. Транспортный протокол, как и TCP, обеспечивающий надежную упорядоченную передачу данных. Протокол был разработан в 2000 году, в нем исправлены недостатки TCP и реализованы многопоточность (внутри одного соединения может быть несколько каналов), защита от DDoS атак и синхронное соединение между двумя хостами по двум и более независимым физическим каналам. Обмен данными производится в виде сообщений, границы которых сохраняются.

Это не единственные транспортные протоколы, мы не охватили еще достаточно многие, но основные — это TCP и UDP, поверх которых реализуется большинство других.

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

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

Протокол удаленного сетевого обмена TCP был изобретен Робертом Эллиотом Каном совместно с Винтоном Серфом в рамках решения проблемы совместимости систем и каналов связи. Р.Э. Кана и В. Серфа называют отцами-основателями интернета.

В DoD-модели уровнем выше транспортного реализуются протоколы для обмена данными между приложениями.

Адресация в протоколах

Некоторые протоколы вводят свою адресацию, например:

• Протоколы сетевого уровня, например IP, вводят адреса узлов и сетей.

• Протоколы канального уровня, например Ethernet, вводят адреса оборудования, или MAC-адреса, для того чтобы доставить кадры на правильный узел из множества подключенных к шине. MAC-адрес используется даже в соединениях типа «точка-точка», хотя в этом случае он является просто рудиментом.

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

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

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

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

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

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

Примеры топологий представлены на рис. 1.5.

Рис. 1.5. Топологии сетей

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

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

Задачи, которые решает книга

В книге мы будем писать код для компьютерных сетей, которые имеют следующие особенности:

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

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

• Абсолютное большинство таких сетей стандартизированы и основаны на стеке TCP/IP. Когда-то для локальных сетей был популярен стек IPX/SPX, но с угасанием популярности ОС Novell NetWare он уступил место TCP/IP и больше практически не встречается.

Большую часть внимания в книге мы уделим следующим вопросам:

• изучению и использованию того, что реализовано выше транспортного уровня OSI;

• изучению API и библиотек;

• реализации собственных простых протоколов прикладного уровня.

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

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

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

• Приложения реального времени, которые должны знать, сколько времени требуется на запрос и ответ. В ОС, которые не являются системами реального времени, зачастую недопустимы задержки, которые вносит стек ОС. Как пример — VoIP-приложения, которые могут работать поверх сетевого уровня или даже ниже, самостоятельно формируя дейтаграммы и пакеты.

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

• Экспериментальные реализации сетевых протоколов, в том числе протоколов канального уровня: PPP, MPLS и др.

• Конфигурирование и настройка сетевого оборудования.

• Использование служебных протоколов: ICMP, IGMP, SNMP и др.

• Реализация взаимодействия с операционными системами, которые не поддерживают TCP/IP. Например, QNX 4.25 использует собственные протоколы. А простейшим сетям на микроконтроллерах зачастую не хватает ресурсов памяти и CPU, чтобы выполнять полный код стека со всеми проверками и условиями.

Все эти случаи мы рассмотрим в книге и некоторые из них — подробно.

Служебные протоколы, используемые в стеке TCP/IP, мы разберем отдельно. Прежде всего поговорим о протоколе ICMP — Internet Control Message Protocol, который позволяет узнавать состояние отдельных узлов сети.

В остальном даже протоколы, необходимые для взаимодействия маршрутизаторов, реализуются поверх TCP, как, например, BGP, или поверх IP, как например, OSPF. И поэтому работе с протоколами, реализованными поверх TCP и IP, мы уделим больше внимания.

Ресурсы, необходимые для работы

Ни одна книга не может быть всегда актуальной и такой же точной, как спецификации и код.

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

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

RFC — Request For Comments

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

Вот некоторые примеры:

• RFC 1 «Host Software»1 — программное обеспечение узла. Первое, уже не вполне актуальное RFC.

• RFC 791 «Internet Protocol»2 — описание IP.

• RFC 768 «User Datagram Protocol»3 — описание UDP.

• RFC 793 «Transmission Control Protocol»4 — описание TCP.

• RFC 1011 «Official Internet Protocols»5 — описание стандартных протоколов Internet.

• RFC 1180 «A TCP/IP Tutorial»6 — туториал по TCP/IP.

• RFC 2553 «Basic Socket Interface Extensions for IPv6»7 — расширения сокетного API для IPv6.

• RFC 8085 «UDP Usage Guidelines»8 — правила использования UDP.

В RFC содержится практически вся информация, необходимая для понимания и реализации протоколов, а значит, и сетевого взаимодействия. Но зачастую их тяжело читать. К тому же они дают лишь понимание того, как работает протокол, но не о том, какие реализации существуют, если только это не RFC о реализациях. Таким образом, RFC — это отправная точка для тех, кто реализует протокол, и тех, кто хочет точно понимать все нюансы.

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

Стандарты

После написания RFC и частичной проверки работы основные протоколы стека TCP/IP были закреплены в различных стандартах, таких как:

• MIL-STD-1777для IP, MIL-STD-1778 для TCP.

• IEEE Std 1003.1-20179 — стандарт на API операционной системы.

• Стандарты от X/Open и OpenGroup.

• Прочие стандарты, которые выпускаются организациями, такими как W3C или OMG, и перечислены в книге 2.

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

Команды ОС

Мы предполагаем, что большинство разработчиков сетевых приложений будут работать в Unix-подобных операционных системах, таких как Linux.

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

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

На ОС Windows до сих пор работает большинство пользователей, если не учитывать мобильные устройства.

Далее указаны не все команды. Например, существует команда locate, в современных системах Linux устанавливаемая из пакета slocate. Она ведет поиск по базе данных, которую требуется иногда актуализировать.

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

Manual pages

Команда man в Linux выдает информацию о большинстве параметров системы, если установлены man-страницы.

Примеры:

• man 3 socket покажет описание C-функции socket(), используемой для создания сокетов.

• man 7 socket в Linux даст описание Linux Socket Interface.

В некоторых дистрибутивах Linux, например в Arch и Gentoo, man-страницы включены в пакет с основными страницами и чаще всего предустановлены.

В Debian и дистрибутивах на его основе пакеты с man-страницами поставляются отдельно.

Основные man-страницы находятся в пакете manpages. А страницы в разделе 3 или 3p обычно идут в пакете manpages-posix-dev.

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

Man-страницы также доступны онлайн на многих ресурсах, например:

• https://linux.die.net/ — сайт die.net содержит коллективные проекты нескольких десятков друзей из США. В частности, linux.die.net предоставляет доступ к документации Linux.

• https://man7.org/ — сайт Майкла Керриска, автора книги «Linux API Interface». Предоставляет доступ не только к man-страницам онлайн, но и к полезным ресурсам, таким как ссылки на статьи, описывающие различные опции.

• https://ru.manpages.org/ — русский перевод man-страниц.

• https://www.opennet.ru/man.shtml — русские man-страницы на проекте OpenNet.

• Специфичные для ОС man страницы доступны на ресурсах ОС:

• https://www.freebsd.org/ — FreeBSD.

• https://man.netbsd.org/ — NetBSD.

• https://man.openbsd.org/ — OpenBSD.

• https://docs.oracle.com/en/operating-systems/ — документация различных систем Oracle, man также доступен в отдельном разделе.

• https://www.ibm.com/docs/ — документация продуктов IBM. Например, описание man по AIX 7.3: https://www.ibm.com/docs/ru/aix/7.2?topic=m-man-command.

Внимание! В разных разделах находятся разные man-страницы, которые могут иметь одинаковое название: в Linux команда man 2 daemon выводит страницу о функции daemon(), а команда man 7 daemon — страницу с инструкцией о том, как написать демон правильно. Иногда пишут daemon(2) — это означает man 2 daemon.

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

Команда whatis

Показывает, в каких разделах искать страницы man. Пример: whatis socket будет искать по названиям страниц и, среди прочего, покажет, какие аспекты socket описаны в разделах 3 и 7:

➭ whatis socket

socket (7)           - Linux socket interface

socket (n)           - Open a TCP network connection

Socket (3perl)       - networking constants and support functions

socket (3p)          - create an endpoint for communication

socket (2)           - create an endpoint for communication

Команда info

Выводит интерактивное руководство в системе Info. Пример: info socket выдаст страницу руководства, где простым языком описано, как создать сокет и для чего, как его потом закрыть и что с ним делать.

Команда apropos

Ведет поиск не только по именам, но и по кратким описаниям. Вывод команды объемен — прибегать к ней лучше в крайнем случае:

➭ apropos socket

IO::Socket::IP (3perl) - Family-neutral IP socket supporting both IPv4 and IPv6

Socket (3perl)       - networking constants and support functions

accept (2)           - accept a connection on a socket

accept (3p)          - accept a new connection on a socket

accept4 (2)          - accept a connection on a socket

address_families (7) - socket address families (domains)

...

zmq_setsockopt (3)   - set 0MQ socket options

zmq_socket (3)       - create 0MQ socket

zmq_socket_monitor (3) - monitor socket events

zmq_socket_monitor_versioned (3) - monitor socket events

zmq_unbind (3)       - Stop accepting connections on a socket

zmq_vmci (7)         - 0MQ transport over virtual machine communication interface (VMCI) sockets

Команды пакетной системы

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

Примеры:

• apt-cache search 'socket' в системах на основе Debian будет искать пакеты, в описании или названии которых есть «socket».

• pacman -Qs ".*socket.*" — то же самое, но в системах на основе Arch.

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

Документация ядра Linux

Если установлен соответствующий пакет, например kernel-doc, в подкаталогах /usr/share/doc/kernel-doc-<версия>/Documentation будут доступны подкаталоги текстовых файлов с проектными решениями, принятыми в ядре Linux. Документацию по разделам можно найти на сайте https://www.kernel.org/doc/html/latest/networking/index.html.

В книге при указании ссылок на документацию ядра путь дается относительно корня, содержащего документацию каталога.

Стоит помнить, что для Linux доступен исходный код, в том числе на Github-зеркале10.

Документация для Microsoft Windows

Для разработчиков под ОС Windows компания Microsoft предоставляет базу данных с достаточно полным описанием API Windows, в том числе API для работы с сокетами. Она называется MSDN Library — библиотека MicroSoft Developer Network. Ранее она могла быть установлена вместе с MS Visual Studio, а сейчас доступна на общем портале docs.microsoft.com11.

Там же доступна библиотека портала Microsoft TechNet12, ориентированная преимущественно на других IT-профессионалов и опытных пользователей.

Примеры кода доступны:

• на Github в организации Microsoft Archive13;

• на портале learn.microsoft.com.

Исходный код ОС Windows

Для ОС Windows, как правило, возможности посмотреть исходный код нет. Но для версии XP код появился в сети и сейчас доступен на GitHub: https://github.com/ufwt/windows-XP-SP1. Его изучение может помочь в решении некоторых вопросов.

Кроме того, концепции решений можно посмотреть в исходном коде свободного аналога ОС Windows — ReactOS: https://github.com/reactos/reactos. Однако стоит понимать, что их реализация в ОС Windows может значительно отличаться.

Документация по языкам программирования

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

• для C++ существуют:

• описание Стандарта на https://isocpp.org/std/the-standard;

• сайт cppreference.com;

• многие другие сайты, описывающие STL;

• для Python существуют:

• документы Python Enhancement Proposals, или PEP, на https://peps.python.org/. Например, из PEP 475 Retry system calls failing with EINTR можно узнать, как Python должен обрабатывать ошибку EINTR, что пригодится нам при обмене данными;

• полная документация по его библиотеке: https://docs.python.org/3/.

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

Необходимо понимать, что реализованное в библиотеках большинства языков API опирается на интерфейс сокетов, который мы рассмотрим далее, в частности, на примере языка Go в конце следующей главы. Это в основном справедливо и для прочих языков. Поэтому если вы хотите действительно разобраться в теме, данный интерфейс потребуется изучить.

Сайт StackOverflow

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

Также надо отметить, что StackOverflow — это лишь одно из сообществ, которые предоставляет сервис StackExchange15.

Некоторые вопросы могут рассматриваться в других его сообществах, прежде всего:

• Unix & Linux16;

• Ask Ubuntu17;

• Network Engineering18.

Сообщества StackOverflow поддерживаются на разных языках, в том числе на русском: https://ru.stackoverflow.com/.

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

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

Прочие ресурсы

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

Кроме того, иногда бывают полезны блоги компаний, занимающихся сетевыми технологиями, например CloudFlare: https://blog.cloudflare.com/.

Нововведения в Linux, в том числе в сетевой части, обычно публикуются на сайте LWN.net19— новостном сайте для разработчиков. Там можно найти описания многих опций ядра, которые в ином месте не документированы.

И конечно, нельзя забывать о литературе, среди которой нужно выделить до сих пор актуальную книгу Ричарда Стивенса «UNIX. Разработка сетевых приложений».

Иногда подсказку может дать и бот сети ChatGPT или его аналоги. Но его ответы не всегда точны, особенно если запрос выходит за рамки примитивных и типовых.

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

Как пример, на вопрос о том, откуда пошло название «пакет» для PDU IP, бот ChatGPT ответил, что из RFC 791 «Internet Protocol». Но в данном RFC PDU IP называется «дейтаграмма» — datagram.

Примитивы, используемые для сетевого взаимодействия

Чтобы процессы могли обмениваться данными по сети, необходимо создать канал обмена. Проще всего представить канал как две оконечные точки, через которые происходит чтение или запись данных. Сокет, или «розетка», — это и есть оконечная точка, условный пример которой показан на рис. 1.6.

Рис. 1.6. Так можно представить сокет

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

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

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

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

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

Как и при обмене по обычной сети, будет поступать подтверждение получения данных: ACK, TCP будет выполнять управление потоком, а IP — выполнять разбивку данных на пакеты размером в максимальный объем передачи данных интерфейса (MTU) и т.д.

Для работы с сокетами система предоставляет API.

Клиент и сервер

Сокетное API уровня транспортного протокола и выше обычно предполагает наличие вызывающего и отвечающего абонентов. Часто их называют «клиент» и «сервер». Но стоит различать эти понятия на разных уровнях протоколов:

• На транспортном уровне сервер — этот тот, кто ожидает подключения, а клиент — тот, кто подключается.

• На прикладном уровне сервер — тот, кто предоставляет услугу. В ответ на запрос он может вернуть данные, перевести деньги на счет, включить двигатель или установку и т.д. А клиент — тот, кто запрашивает услугу. Как правило, в ответ он получает данные, хотя бы о том, что запрос принят или не принят сервером.

Например, в случае протокола FTP сервер — это тот, кто предоставляет услугу хранения данных некоторым клиентам. Если же FTP работает в активном режиме, сервер на транспортном уровне в начале его работы представляет собой то же самое, что и сервер на прикладном уровне: он ждет подключения, обычно на порт 21.

Клиент подключается к нему и отправляет запрос. Но затем клиент и сервер меняются местами: клиент ожидает подключения на порт 20, и к нему подключается сервер. То есть на транспортном уровне сервер теперь клиент, а клиент — сервер. Такое поведение обеспечивает наличие независимого канала управления передачей данных, которое осуществляется по второму каналу.

Обычно, когда мы говорим «клиент» или «сервер», мы подразумеваем работу на прикладном уровне, если не указано обратное.

Сокеты Беркли, POSIX API

Технология сокетов была разработана в Университете Беркли, а затем стандартизирована IEEE в рамках POSIX — Portable Operating System Interface.

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

• заголовочных файлов для C;

• структур данных;

• функций.

Сейчас сокеты POSIX — наследники сокетов Беркли — один из вариантов сокетного API.

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

Первое название стандарта — Berkeley Sockets API. Последняя на момент написания книги версия стандарта — POSIX.1-201720 или, более полно, The Open Group Base Specifications Issue 7, 2018 edition.

Сокетный интерфейс описан в разделе 2.10.

Почему важно знать о сокетах

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

Для опытного же разработчика нет смысла «изобретать велосипед»: в библиотеках сделано все то же самое, при этом хорошие библиотеки реализованы эффективно.

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

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

Сокетный интерфейс не первый и не единственный. Просто он более зрелый и широко используемый.

Кроме сокетного интерфейса, в AT&T UNIX System V Release 3 и AT&T Solaris существовал Transport Layer Interface — TLI. Его преемником стал стандартизованный TLI — X/Open Transport Interface21, или XTI. Этот интерфейс похож на сокетный, но все его функции начинаются с префикса t_. Кроме того, он содержит функции, отсутствующие в интерфейсе сокетов: t_unbind(), t_alloc(),  t_sync() и т.п. Многие из них легко реализовать, используя сокеты. Например, t_unbind() — это bind() с параметром AF_UNSPEC в семействе адресов.

Этот интерфейс был реализован в Unix поверх фреймворка STREAMS — конвейера сопрограмм, передающего сообщения между приложениями или между приложением и драйвером. Сейчас подобный фреймворк используется в системе Plan 9.

XTI/TLI также использовался в системах Novell, сеть в которых была реализована на базе стека протоколов IPX/SPX.

Характеристики сокетов

Всем сокетам при создании задаются некоторые характеристики. И только сокеты с полностью одинаковыми характеристиками можно связать друг с другом. Рассмотрим эти характеристики подробнее.

1. Семейство протоколов, или коммуникационный домен и семейство адресов

Существует более 30 семейств протоколов, все они перечислены в файле socket.h. Иногда семейства называют доменами.

socket.h на разных платформах

В Linux данные семейства определены в заголовочных файлах LibC.

Например, для GLibc в Debian и дистрибутивах-наследниках это пакет libc6-dev, где номер зависит от версии библиотеки.

Поскольку данный пакет для разных платформ различается, сам файл находится по соответствующему пути. Например, для платформы x86_64, то есть 64-битных Intel-совместимых машин, путь следующий: /usr/include/x86_64-linux-gnu/bits/socket.h.

В дистрибутивах, унаследованных от Arch Linux, файл содержится в пакете core/glibc. Сам же файл находится по пути /usr/include/bits/socket.h.

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

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

Внимание! В создаваемых на C++ приложениях нужно включать файл sys/socket.h, а не bits/socket.h.

Примеры семейств протоколов:

• PF_INET — Internet Protocol версии 4. Это обычный TCP/IP на IPv4.

• PF_UNIX — локальные сокеты, или Unix sockets. Другое название — PF_LOCAL.

• PF_INET6 — Internet Protocol версии 6 или IPv6.

• PF_LINK — интерфейс канального уровня.

• PF_IEEE80211 — IEEE 802.11 канальный интерфейс беспроводных протоколов, Wi-Fi.

• PF_NETGRAPH — сокеты Netgraph. Это специфичные для FreeBSD сокеты. Netgraph — модульная сетевая подсистема, основанная на графах.

• PF_INET_SDP и PF_INET6_SDP — сокеты OpenFabrics Enterprise Distribution. Сокеты Mellanox OFED22. Под этим типом сокетов лежит отдельный стек, работающий на адаптерах Mellanox и обеспечивающий функционирование Virtual Protocol Interconnect23. Это стек инфраструктуры датацентров.

• PF_BLUETOOTH — сокеты для работы со стеком протоколов Bluetooth. Bluetooth имеет свой отдельный большой стек протоколов со своими типами сокетов.

• PF_IPX — протоколы IPX/SPX Novell, для локальных сетей 1990-х.

Семейств протоколов множество, и к ним регулярно добавляются новые, а какие-то устаревают. В книге невозможно рассмотреть их все, но на некоторых мы будем останавливаться по ходу изложения. Самые важные для нас семейства — PF_INET и PF_INET6, которые содержат протоколы стека TCP/IP.

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

• AF_INET соответствует PF_INET.

• AF_UNIX соответствует PF_UNIX.

• AF_INET6 соответствует PF_INET6.

• AF_BLUETOOTH соответствует PF_BLUETOOTH.

• AF_IPX соответствует PF_IPX.

PF-макросы берут свое начало из BSD-систем. Сейчас в большинстве случаев их можно заменить AF-макросами. Новые стандарты используют AF-макросы вместо PF, и это не ошибка.

Префикс PF_ означает Protocol Family, префикс AF_ — Address Family.

Посмотреть, какие семейства адресов поддерживаются в Linux, можно, введя следующую команду:

➭ man address_families

2. Тип

Определяет группу транспортных протоколов со схожим поведением.

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

• SOCK_STREAM — упорядоченный поток байтов с гарантированной доставкой, основанный на соединении. К этому типу относят сокеты, использующие TCP, но не любой сокет этого типа является TCP-сокетом. Основное свойство данного типа — поток без границ между порциями данных.

• SOCK_DGRAM — отправка дейтаграмм без соединения, гарантии доставки или сохранения порядка. UDP может работать как протокол для сокета этого типа. Данный тип сокетов характеризуется наличием отдельных сообщений.

• SOCK_RAW — сырые сокеты. Данные передаются поверх канального уровня OSI. Они поддерживаются в большинстве операционных систем, включая Windows, начиная с версии XP.

• SOCK_RDM — сокеты, ориентированные на надежную доставку сообщений. Нужны для реализации протоколов 4-го уровня. Они «надежнее, чем UDP, и отзывчивее, чем TCP». Ориентированы на сообщения.

• SOCK_SEQPACKET — обеспечивает последовательную, надежную, двунаправленную передачу записей в режиме соединения. Для семейства интернет-сокетов это протокол SCTP.

• SOCK_DCCP — сокет минимального транспортного протокола общего назначения. Управляет созданием и завершением ненадежного потока пакетов и отслеживанием перегрузок. Заменяет UDP при реализации протоколов трансляции потоковых данных в реальном времени.

• SOCK_PACKET — специфический для Linux способ получения пакетов. Используется разработчиками сетевого стека для написания служебных протоколов, таких как RARP и подобных, на уровне пользователя.

Допустимые типы для сокета зависят от того, в какой домен PF_* он входит.

3. Используемый протокол

В семействе PF_INET, например, может использоваться протокол IPPROTO_TCP для типа SOCK_STREAM и IPPROTO_SCTP для SOCK_SEQPACKET. А для SOCK_DGRAM — IPPROTO_UDP или IPPROTO_UDPLITE.

В общем случае в каждом семействе протоколов может быть несколько протоколов для каждого из типов. Но часто одному семейству соответствует один протокол. В семействе PF_UNIX протокол вообще не используется, он всегда равен 0.

Также существуют специальные значения протокола, которые передаются в функцию socket(), описанную ниже. Например, IPPROTO_IP со значением 0. Это значение предполагает разное поведение для разных комбинаций типа сокета и семейства. Например, для комбинации AF_INET/SOCK_STREAM передача нуля заставит стек использовать протокол по умолчанию, что будет эквивалентно IPPROTO_TCP. Для AF_INET/SOCK_DGRAM оно будет эквивалентно IPPROTO_UDP. В raw-сокетах поведение вызова с этим флагом зависит от конкретной ОС.

Опции

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

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

Другие применения сокетов

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

Например:

• Сокеты AF_ALG используются для взаимодействия с Linux Crypto API.

• Сокеты AF_NETLINK используются для взаимодействия с различными подсистемами ядра Linux, обычно связанными с сетевым стеком. При использовании данных сокетов протокол NETLINK_FIREWALL даст возможность получать данные от встроенного межсетевого экрана, а NETLINK_KOBJECT_UEVENT — читать события ядра. На основе этого работает подсистема устройств udev.

• AF_VSOCK обеспечивает взаимодействие гипервизора и виртуальной машины.

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

Заголовочные файлы C в POSIX-системах

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

Стандарт ориентируется на язык C, поскольку большинство ОС реализовано на данном языке. Но как мы увидим далее, в других языках сокетный интерфейс во многом повторяет его структуру. POSIX-стандарт также распространяется на расположение и названия заголовочных файлов языка C.

В Unix-подобных системах заголовочные файлы, в которых определен сокетный POSIX API, поставляются вместе с ОС:

• sys/socket.h — базовые функции сокетов и основные структуры данных.

• netinet/in.h — семейства адресов/протоколов AF_INET/PF_INET и AF_INET6/PF_INET6. Включают в себя IP-адреса, а также номера портов TCP и UDP.

• sys/un.h — семейство адресов PF_UNIX/PF_LOCAL.

• arpa/inet.h — функции для работы с числовыми IP-адресами.

• netdb.h — функции для преобразования протокольных имен и имен хостов в числовые адреса.

И так далее.

В ОС Windows состав заголовочных файлов отличается, они будут рассмотрены в главе 14. Сейчас достаточно знать, что большинство функций из этой главы доступны через включение заголовочных файлов winsock2.h или winsock.h.

Основные функции

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

• Функции создания и уничтожения сокета, которые мы рассмотрим в этой главе:

• socket() — создает новый сокет определенного семейства протоколов и адресов, идентифицируемого целым числом, и выделяет ему системные ресурсы.

• close() — закрывает сокет и освобождает выделенные ресурсы. В случае TCP-сокета соединение разрывается. Данная функция сама вызовет функцию shutdown(), если это необходимо.

• shutdown() — корректно закрывает канал для обмена данными в одну или обе стороны.

• Работа с адресами будет рассмотрена в главе 2:

• bind() — связывает сокет со структурой адреса, например с указанным IP-адресом и номером порта.

• gethostbyname(), gethostbyaddr() — разрешение имен и адресов хостов. Применяются только для IP.

• getaddrinfo() — функция, возвращающая список адресов по имени.

• getnameinfo() — функция, возвращающая список имен по адресу.

• Отправка и получение данных, которые мы начнем рассматривать в главе 4:

• send(), sendto(), sendmsg() — отправить данные:

• Функция send() используется, когда адрес привязан к сокету, например, в случае TCP-соединения.

• Функция sendto() используется, когда сокет не ориентирован на соединение, например, для UDP. В случае UDP также может использоваться send(), если предварительно был вызван connect().

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

• recv(), recvfrom(), recvmsg() — принять данные:

• Функция recv() используется, когда адрес привязан к сокету, например, в случае TCP-соединения.

• Функция recvfrom() используется, когда сокет не ориентирован на соединение, например, для UDP. Так же как и для send(), для сокетов без установления соединения может быть использован recv().

• Функция recvmsg() аналогична sendmsg(), но работает на прием.

• В POSIX- и BSD-сокетах для отправки и приема данных также можно использовать стандартные функции write() и read(). Но send()/recv() кросс-платформенные и позволяют задать дополнительные опции.

• Оптимизированные функции, такие как sendmmsg(), recvmmsg(), writev(), readv(), sendfile() и пр., мы рассмотрим в книге 2.

• Работа с сокетами, ориентированными на соединение, будет рассмотрена в главе 5:

• connect() — используется на стороне клиента и привязывает сокет к адресу. Присваивает сокету свободный номер локального порта. В случае TCP-сокета это инициирует попытку установить новое TCP-соединение.

• listen() — используется на стороне сервера и указывает сокету, к которому привязан адрес, перейти в состояние прослушивания. Работает не для всех протоколов, но применимо, например, для TCP.

• accept() — используется на стороне сервера, принимает входящую попытку создать новое TCP-соединение от удаленного клиента и создает новый сокет, связанный с соединением.

• Оптимизированные нестандартные функции, такие как connectx(), мы рассмотрим в книге 2.

• Управление сокетами будет рассмотрено в главе 8:

• getsockopt() — получение текущего значения указанной опции сокета.

• setsockopt() — установка конкретной опции сокета.

• В главах 10–11 мы изучим управление сетевыми интерфейсами.

• Асинхронная работа рассматривается преимущественно в книге 2:

• select() — ожидает готовности сокетов из списка к чтению, записи или переходу в состояние ошибки.

• poll() — проверяет состояние сокета в списке: готов ли сокет к записи или чтению данных либо возникла ошибка. Как альтернатива select(), работает быстрее и не ограничивает число дескрипторов в списке. Упрощает проверку сокетов в многопоточных приложениях.

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

_r функции

В Glibc часто можно встретить функции с суффиксом _r. Например: gethostbyname_r(), gethostbyaddr_r(), strerror_r() и подобные. Суффикс _r — re-entant — означает, что функции, реентерабельны24, то есть могут одновременно быть вызваны несколько раз, например из разных потоков.

Glibc создавался в те времена, когда многопоточные приложения еще не были популярны. Функция могла хранить, например, статический буфер, который использовался для формирования результата. Такая функция не была потокобезопасной. Позже для этой проблемы нашли несколько решений. Например, переменная errno хранится в локальном хранилище потока.

Часть функций API, представленного Glibc, получила суффикс _r и дополнительный параметр — буфер, в котором функция формирует результат и который передается ей пользователем.

Начнем подробное рассмотрение функций с создания и уничтожения сокета.

Функции socket() и close()

Создать объект сокета можно с помощью функции socket(). Она определена в sys/socket.h.

Прототип функции socket():

// Только для POSIX-совместимых ОС и стеков.

// Для ОС Windows заголовочные файлы другие.

#include <sys/socket.h>

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

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

• domain — коммуникационный домен, упомянутый выше;

• type — тип сокета;

• protocol — используемый протокол.

Возвращаемое значение — дескриптор нового сокета либо –1 в случае неудачи. Для ОС Windows это константа INVALID_SOCKET.

В Linux с версии 2.6.27 второй параметр функции socket(), помимо типа протоколов, также позволяет задавать некоторые флаги сокета:

• SOCK_NONBLOCK — установить неблокирующий режим при открытии сокета. О том, что это значит, будет рассказано позже.

• SOCK_CLOEXEC — закрыть дескриптор, если процесс или его потомки выполняют exec() и подобные функции. Предотвращает утечку дескрипторов.

Пример использования этих флагов:

int new_socket = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, IPPROTO_UDP);

Python также поддерживает эту возможность, но такой код не обязательно будет кроссплатформенным.

Функция close() закрывает сокет. Эта функция определена в unistd.h:

#include <unistd.h>

int close(int fd);

Здесь fd — дескриптор сокета, который требуется закрыть.

В ОС Windows используется closesocket() из winsock2.h. Про отличия Windows мы поговорим в главе 16.

Вызов функции socket() создает объект ядра «сокет» и, в случае успеха, возвращает его дескриптор — уникальный в рамках процесса идентификатор этого объекта:

extern "C"

{

#ifndef _WIN32

#    include <sys/socket.h>

#    include <unistd.h>

#else

#    include <winsock2.h>

#endif

}

...

// Создать новый UDP-сокет.

int new_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

...

#ifdef _WIN32

closesocket(new_socket);

#else

close(new_socket);

#endif

При завершении программы функция close() вызывается автоматически: ОС самостоятельно закроет сокет. Вызов функции close() для сокетов, ориентированных на соединение, инициирует вызов shutdown().

Внимание! Функция close() не так проста, как может показаться. Ее использование предполагает множество нюансов. Для TCP, например, данная функция не завершится сразу и может «зависнуть» на несколько секунд. Мы будем описывать ее особенности по мере освоения порядка работы с протоколами.

Функцию close() нужно вызывать явно — это признак хорошего стиля и предупреждает утечки ресурсов.

Внимание! Функция close() может не завершить соединение и не закрыть сокет, если на соединение будет ссылаться более, чем  один дескриптор, например созданного через вызов dup(). В этом случае функция лишь закроет один из дескрипторов.

Теперь посмотрим, как описанное выше реализуется в Python. Сокетный интерфейс содержится в модуле socket, а большая часть его функций инкапсулирована в класс socket. Сокеты в Python являются объектами этого класса:

# Импортировать модуль для работы с сокетами.

import socket

# Создать новый UDP-сокет.

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

# Распечатать объект сокета.

print(s)

# Закрыть сокет.

s.close()

print(s)

Вывод будет следующим:

<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=17, laddr=('0.0.0.0', 0)>

<socket.socket [closed] fd=-1, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=17>

Видим, что сокет был успешно создан, после чего успешно закрыт. После закрытия Python установил дескриптор fd в значение –1, которое не является корректным значением дескриптора. Проверив его, можно убедиться, что данный объект не является корректным сокетом. Также явно видно состояние: "[closed]".

В Python создать новый сокет можно и из существующего дескриптора сокета, используя функцию fromfd() или параметр конструктора fileno класса socket.socket:

# Создать объект класса socket из дескриптора сокета.

def fromfd(fd, family, type, proto=0) -> socket

class socket:

def__init__(self, family=-1, type=-1, proto=-1, fileno=None):

...

Внимание! Функция fromfd() продублирует дескриптор сокета, в результате чего закрытие предыдущего дескриптора не вызовет разрыва соединения, если только явно не был вызван метод shutdown().

Внимание! Функция fromfd() доступна не везде, некоторые ОС ее не поддерживают.

Допустима и подобная конструкция:

with socket.socket(

socket.AF_INET,

socket.SOCK_STREAM,

socket.IPPROTO_TCP

) as s:

# Тут что-то делаем с данным сокетом.

s.connect(("www.python.org", 80))

При выходе из блока with сокет будет корректно закрыт.

Объекты класса socket.socket в Python имеют следующие атрибуты:

• family — семейство адресов;

• type — тип сокета;

• proto — протокол.

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

В случаях, когда требуется работать только с дескриптором, его можно отвязать от объекта socket.socket, используя метод socket.detach():

def socket.detach() -> int

Функция возвращает дескриптор. После этого вызова объект сокета будет переведен в закрытое состояние. Но в действительности дескриптор закрыт не будет и может продолжать использоваться.

Сокетный интерфейс в Python

Выше мы показали, что в CPython не стали изобретать новый интерфейс, а просто транслировали интерфейс C на объекты. При этом объектный интерфейс сокетов в Python значительно упрощает работу и является кросс-платформенным.

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

В текущей реализации класс socket — это наследник класса _socket.socket, псевдонима SocketType:

static PyType_Spec sock_spec =

{

.name = "_socket.socket",

.basicsize = sizeof(PySocketSockObject),

.flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE |

Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE),

.slots = sock_slots,

};

В последующих реализациях детали могут измениться.

Класс _socket.socket в CPython реализован на C, он напрямую работает с интерфейсом POSIX-сокетов.

Сам класс является важной встроенной частью языка Python и реализован в файле Modules/socketmodule.c.

Класс SocketType в CPython сгенерирован из расширения Python, реализованного на C, которое работает с интерфейсом POSIX-сокетов. Само расширение поставляется вместе с библиотекой CPython и реализовано в файле Modules/socketmodule.c. Это общий подход для всех языков высокого уровня, кроме C++.

Сетевой порядок байтов и преобразование порядков

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

Число — абстрактное понятие, и существует много различных представлений чисел, используемых в компьютерах. Например, двоично-десятичный код, или BCD, в котором 4 бита используются для представления цифр от 0 до 9, а некоторые комбинации битов запрещены. В ассемблере процессоров Intel сохранились даже команды для работы с такими числами. Но чаще всего числа представляются как цепочка байтов фиксированного размера — слово, каждый из байтов которого содержит по два шестнадцатеричных разряда.

Порядок чтения этих байтов в слове может быть следующим:

• Big-endian — от старшего байта в слове к младшему, слева направо, или «старший байт по старшему адресу». Например, число, которое пришло как 0x00 0x01 0x02 0x03, в таком порядке будет считано как 0x00010203. Это «прямой порядок».

• Little-endian — от младшего байта в слове к старшему, «обратный порядок», справа налево, или «младший байт по старшему адресу». Число, пришедшее как 0x00 0x01 0x02 0x03, в данном порядке будет считано как 0x03020100.

• Middle-endian — гибридный. Используется для работы с числами, размер которых превышает машинное слово. Порядок байтов зависит от архитектуры, порядок слов обратный. Порядок реализован в ARM для вещественной арифметики и в рамках книги не представляет интереса.

Особенности, характерные для протоколов TCP/IP:

• Строго говоря, эти протоколы не оперируют байтами. Причина в том, что хотя сейчас и принят размер байта в 8 бит, на практике на разных машинах он может различаться. Сетевые протоколы оперируют словами, которые состоят из октетов — 8-битных последовательностей. Размер слова в TCP — 4 октета, или 32 бита.

• Принят прямой порядок октетов, который в этом контексте называется сетевым.

Немного истории байта и бита

На практике, когда люди говорят «байт», они подразумевают «октет». Но слово «байт» (или byte) не означает 8 бит. Изначально это слово «bite» — «кусок», «часть», искаженное для того, чтобы было проще отличать его от bit.

В свою очередь, бит является сокращением от BInary digiT. Это слово использовал Клод Шеннон в работе «Математическая теория связи» 1948 года.

Первые байты появились в 1956 году на машине IBM 7030 Stretch для обозначения пачки битов, передаваемых одновременно. Эти байты состояли из 6 бит.

Второй источник происхождения термина — система SAGE, или Semi-Automatic Ground Environment, — предшественник систем управления NORAD. Над ней в том числе работали сотрудники IBM, так что не исключено, что они пересекались с разработчиками суперкомпьютеров.

Советские ЭВМ, такие как БЭСМ-6 и М-220, также имели байты размером 6 бит. При этом размер машинного слова БЭСМ-6 составлял 48 бит. А в советской ЭВМ «Сетунь» 1959 года, показанной на рис. 1.7, вообще использовались триты — разряды с тремя состояниями — и байты, соответственно, назывались трайтами, имея размер в 9 трит.

Рис. 1.7. Работа на ЭВМ «Сетунь»

Только почти через 10 лет в IBM System/360, выпущенной в 1964 году, стали использоваться 8-битные байты, ставшие со временем стандартом.

Разные процессоры могут работать с разным порядком байтов:

• Процессоры Intel используют little-endian.

• Процессоры ARM тоже работают с little-endian, но могут переключаться на big-endian.

• Процессоры z/Architecture в мейнфреймах IBM, SPARC до версии 9, а также контроллеры Atmel AVR32 используют big-endian.

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

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

Каждая машина имеет «внутреннюю совместимость», то есть при интерпретации всегда читает собственные записанные данные в корректном порядке. Но это не означает, что машина другого типа интерпретирует эти данные таким же образом.

Платформы MIPS и ARM могут работать как в режиме big-endian, так и в режиме little-endian. Есть разные сборки ОС, например, под один и тот же процессор, работающий в режиме little endian или big endian.

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

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

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

Для этого напишем простой код:

#include <iostream>

#include <cstdint>

intmain ()

{

// Двухбайтовое значение: один байт — 0x00, второй байт — 0x01.

uint16_t x = 0x0001;

// Приведение адреса значения к указателю на байт.

// Указатель всегда будет содержать адрес первого (младшего) байта.

// В big-endian-машине это байт по младшему адресу.

// В little-endian — наоборот.

// Затем по значению байта (0 или 1) можно определить,

// какой из двух байтов выделен.

std::cout

<< (*(reinterpret_cast<uint8_t*>(&x)) ? "little" : "big")

<<"-endian"

<<std::endl;

}

Результат для процессора Intel Core i5:

➭ g++ src/book01/ch01/cpp/test_byte_order.cpp -o test_byte_order && \

./test_byte_order

little-endian

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

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

Функции описаны в заголовочных файлах endian.h, netinet/in.h или arpa/inet.h:

#include <arpa/inet.h>

// Конвертирует 32-битную беззнаковую величину из локального порядка байтов

// в сетевой.

// Название функции — аббревиатура от Host TONetwork Long.

uint32_t htonl(uint32_t hostlong);

// Конвертирует 16-битную беззнаковую величину из локального порядка байтов

// в сетевой.

// Аббревиатура от Host TONetwork Short.

uint16_t htons(uint16_t hostshort);

// Конвертирует 32-битную беззнаковую величину из сетевого порядка байтов

// в локальный.

// Аббревиатура от Network TOHost Long.

uint32_t ntohl(uint32_t netlong);

// Конвертирует 16-битную беззнаковую величину из сетевого порядка байтов

// в локальный.

// Аббревиатура от Network TOHost Short.

uint16_t ntohs(uint16_t netshort);

Для сетевого порядка в протоколах стека TCP/IP указано, что длина слова составляет 4 байта, или 32 бита, поэтому функции преобразования определены максимум для 32-битных слов, и это htonl().

В Linux реализован гораздо более широкий набор функций преобразования, в том числе функции для 64-битных слов:

#include <endian.h>

uint16_t htobe16(uint16_t host_16bits);

uint16_t htole16(uint16_t host_16bits);

uint16_t be16toh(uint16_t big_endian_16bits);

uint16_t le16toh(uint16_t little_endian_16bits);

uint32_t htobe32(uint32_t host_32bits);

uint32_t htole32(uint32_t host_32bits);

uint32_t be32toh(uint32_t big_endian_32bits);

uint32_t le32toh(uint32_t little_endian_32bits);

uint64_t htobe64(uint64_t host_64bits);

uint64_t htole64(uint64_t host_64bits);

uint64_t be64toh(uint64_t big_endian_64bits);

uint64_t le64toh(uint64_t little_endian_64bits);

А функции типа hton()/ntoh() реализованы через htobe()/betoh():

# if __BYTE_ORDER == __BIG_ENDIAN

/* Порядок байтов узла такой же, как и сетевой порядок байтов, поэтому все эти функции просто возвращают значение аргумента.*/

# define ntohl(x)__uint32_identity (x)

# define ntohs(x)__uint16_identity (x)

# define htonl(x)__uint32_identity (x)

# define htons(x)__uint16_identity (x)

# else

#   if __BYTE_ORDER == __LITTLE_ENDIAN

#    define ntohl(x) __bswap_32 (x)

#    define ntohs(x) __bswap_16 (x)

#    define htonl(x) __bswap_32 (x)

#    define htons(x) __bswap_16 (x)

#   endif

# endif

#endif

Пример вызова функции в C и C++:

#include <arpa/inet.h>

...

uint32_t net_order_var;

...

uint32_t host_order_var = ntohl(net_order_var);

Пример для Python:

import socket

# Результат: 36895

print(socket.htons(8080))

# Результат: 2417950720

print(socket.htonl(8080))

Внимание! Обратите внимание, что значения разные и зависят от предполагаемого размера числа, что нехарактерно для Python.

В ОС Linux и прочих Unix-подобных ОС о порядке байтов можно почитать в man:

man byteorder

man endian

man endianness

Если преобразование на стороне отправителя не выполнено, чтобы определить порядок байтов в данных, полученных из сети, можно добавить к отправляемым данным несколько байт, например b'1100101110111100'.

Ответ 0xCBBC означает, что порядок байтов на отправителе и получателе одинаковый, а ответ 0xBCCB — что данные отправлены в другом порядке и потребуется конвертация, то есть читать отправленные числа необходимо с конца.

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

В MacOS X также реализован свой набор операций для работы с порядком байтов. Их объявления находятся в файле libkern/OSByteOrder.h:

OSSwapHostToBigInt16();

OSSwapHostToLittleInt16();

OSSwapBigToHostInt16();

OSSwapLittleToHostInt16();

OSSwapHostToBigInt32();

OSSwapHostToLittleInt32();

OSSwapBigToHostInt32();

OSSwapLittleToHostInt32();

OSSwapHostToBigInt64();

OSSwapHostToLittleInt64();

OSSwapBigToHostInt64();

OSSwapLittleToHostInt64();

Свои функции конвертации есть и в ОС Windows.

Зачастую, чтобы скрыть различия между ОС, создают и добавляют заголовочный файл portable_endian.h, в котором определяются макросы, имеющие одно и то же имя, но реализованные через функции, предоставленные данной ОС.

Обертка C++ для сокетов

API сокетов в разных ОС не полностью совместимы, и для упрощения работы и минимизации повторений в книге реализована минималистичная обертка, которая скрывает некоторые детали реализации, например инициализацию сокетной подсистемы в ОС Windows. Эту обертку мы используем в коде нескольких первых глав далее. Она реализована как отдельная библиотека socket_wrapper и включает следующее:

• Класс SocketWrapper с реализацией под Unix и под Windows. Класс скрывает инициализацию для ОС Windows, а также предоставляет универсальный интерфейс для получения сообщений об ошибках.

• Класс Socket — класс RAII, инкапсулирующий дескриптор сокета. Он требуется, чтобы скрыть различия в закрытии сокета.

• Несколько полезных функций, назначение которых будет подробнее рассмотрено позже:

• Получение адресной информации для сервера — get_serv_info().

• Разрешение адреса для клиента — get_client_info().

• Различные функции установки опций, например set_reuse_addr().

• Функция приема нового клиента для TCP-серверов: accept_client().

• Функция для создания типового сервера TCP/IP: create_tcp_server().

В конструкторе класс Socket вызывает функцию socket, в деструкторе — закрывает сокет:

namespace socket_wrapper

{

// Конструктор.

Socket::Socket(int domain, int type, int protocol) :

socket_descriptor_(INVALID_SOCKET)

{

open(domain, type, protocol);

}

// Конструктор из дескриптора.

Socket::Socket(SocketDescriptorType socket_descriptor) :

socket_descriptor_(socket_descriptor)

{

}

// Конструктор перемещения.

Socket::Socket(Socket &&s)

{

socket_descriptor_ = s.socket_descriptor_;

s.socket_descriptor_ = INVALID_SOCKET;

}

void Socket::open(int domain, int type, int protocol)

{

if (opened()) close();

// Создать новый сокет.

socket_descriptor_ = socket(domain, type, protocol);

}

...

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

Для разных операционных систем обертка реализована по-разному. Например, метод Socket::close() в POSIX-совместимых ОС вызовет функцию close(), а в ОС Windows — функцию closesocket():

#ifdef _WIN32

#    define close_socket closesocket

#else

#    define close_socket ::close

#endif

namespace socket_wrapper

{

...

int Socket::close()

{

// Закрыть сокет.

const int status = close_socket(socket_descriptor_);

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

socket_descriptor_ = INVALID_SOCKET;

// Метод должен вести себя как функция close().

return status;

}

// В деструкторе сокет будет закрыт.

Socket::~Socket()

{

if (opened()) close();

}

Также класс предоставляет сокетный дескриптор, когда используется в стандартных функциях сокетного API. В случае проверки в условии класс будет преобразован в true, если сокет корректно открыт, то есть содержит корректный дескриптор, иначе — в false. Socket sock = socket(...) легко заменяется на int sock = socket(...):

class Socket final

{

public:

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

explicitSocket(SocketDescriptorType socket_descriptor);

Socket(const Socket &) = delete;

Socket(Socket &&s);

Socket &operator=(const Socket &s) = delete;

Socket &operator=(Socket &&s);

~Socket();

public:

// Проверка на то, что сокет открыт.

bool opened() const noexcept;

public:

// Приведение к bool выполнит проверку.

explicit operator bool() const noexcept { return opened(); }

// Приведение к int вернет дескриптор.

operator SocketDescriptorType() const noexcept

{ return socket_descriptor_; }

public:

int close() noexcept;

private:

void open(int domain, int type, int protocol);

private:

// Значение INVALID_SOCKET разное для POSIX и Windows.

SocketDescriptorType socket_descriptor_{INVALID_SOCKET};

};

Различные константы определены в файле socket_headers.h, например, так определен макрос INVALID_SOCKET:

// Проверка нужна, так как для Windows макрос уже определен.

#if !defined(INVALID_SOCKET)

#    define INVALID_SOCKET (-1)

#endif

#if !defined(SOCKET_ERROR)

#    define SOCKET_ERROR (-1)

#endif

Там же определен тип SocketType, который для ОС Windows будет аналогичен SOCKET, а для Unix-подобных систем — int. Читатель может убедиться в этом самостоятельно, изучив обертку в репозитории книги.

Подробнее работу обертки мы разберем в следующих главах. Например, инициализация в ОС Windows будет рассмотрена в главе 13. Кроме того, на примере данной обертки должно стать понятно, как функционируют и создаются библиотеки.

Можно использовать Libsocket25, Sockpp26 или другую библиотеку C++, которая скрывает особенности работы с сокетами.

Однако, во-первых, на данном этапе нам достаточно простой обертки.

Во-вторых, на этапе обучения полезнее опираться на стандарт, чтобы знать API без прослойки библиотек и понимать, как работает сокетный API. Наша обертка не скрывает эту информацию, убирая «под капот» лишь некоторые рутинные операции.

Резюме

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

Обмен данными принято называть сетевым взаимодействием. Оно представлено стеком уровней, который в компьютерных сетях описывается такими моделями, как OSI, DoD, модель Танненбаума, модель Cisco Academy.

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

На разных уровнях модели имеется своя адресация. Для IP-протокола это IP-адреса узлов и сетей, для Ethernet — МАС-адреса, для TCP и UDP — идентификаторы портов. Для каждого уровня с адресацией может быть свой граф и своя топология.

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

Дополнительная и справочная информация о сетевом программировании доступна в RFC, на страницах man и info для Linux-подобных ОС, в MSDN — для ОС Windows и на таких ресурсах, как StackOverflow.

В сетевом программировании процессы используют сокеты — фундаментальную технологию, на которой основан интернет. Технология сокетов была разработана в Университете Беркли и стандартизирована IEEE в рамках POSIX — Portable Operating System Interface.

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

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

Сокет создается функцией socket(), а закрывается функцией close().

Данные в сокетах при использовании стека TCP/IP передаются с использованием прямого или сетевого порядка байтов. Независимо от того, какой порядок байтов используется на вашем компьютере, перед записью данных в сокет нужно вызывать функции преобразования в сетевой порядок.

Хотя все современные операционные системы так или иначе реализуют POSIX-совместимый интерфейс сокетов, API в разных ОС не полностью совместимы. Поэтому для упрощения работы в книге реализована обертка, которая скрывает некоторые детали реализации, например инициализацию сокетной подсистемы в ОС Windows. Эту обертку мы будем использовать в примерах будущих глав. Она реализована как отдельная библиотека socket_wrapper.

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

1. Что такое сетевое программирование и какова его основная цель?

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

3. Назовите несколько моделей сетевого взаимодействия. Чем они различаются? Какие еще имеются модели, кроме перечисленных в этой главе?

4. Почему часто прикладной и сеансовый уровень и уровень представления модели OSI объединяют в один?

5. Что такое PDU и SDU? В чем их отличие?

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

7. Для чего нужны адреса? Какие бывают адреса?

8. Что такое RFC и каково его значение в контексте стандартов интернет-технологий?

9. Перечислите другие источники информации о сетевом программировании.

10. Для чего нужны разделы в man? Как вывести справку из определенного раздела?

11. Всегда ли стоит доверять решениям на StackOverflow?

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

13. В чем отличие приложений, написанных для клиента и сервера?

14. Что такое сокеты и для чего их можно использовать?

15. Как правильно включить заголовочный файл socket.h?

16. Каковы свойства сокетов?

17. Как создать новый сокет?

18. Как закрыть сокет? Могут ли в процессе закрытия возникнуть проблемы? Если да, то какие?

19. Почему API сокетов может различаться в разных операционных системах?

20. Что представляет собой сокет в Python?

21. Что такое сетевой и хостовой порядок байтов?

22. Для чего и когда следует вызывать функции преобразования в сетевой порядок на компьютерах?

23. Почему в своих приложениях лучше использовать готовые библиотеки?

24. Для чего мы реализуем собственную обертку над сокетным интерфейсом?

25. Самостоятельно создайте и закройте сокет TCP с помощью обертки socket_wrapper и без нее.

1 https://datatracker.ietf.org/doc/html/rfc1

2 https://datatracker.ietf.org/doc/html/rfc791

3 https://datatracker.ietf.org/doc/html/rfc768

4 https://datatracker.ietf.org/doc/html/rfc793

5 https://datatracker.ietf.org/doc/html/rfc1011

6 https://datatracker.ietf.org/doc/html/rfc1180

7 https://datatracker.ietf.org/doc/html/rfc2553

8 https://datatracker.ietf.org/doc/html/rfc8085

9 https://standards.ieee.org/ieee/1003.1/7101/

10 https://github.com/torvalds/linux

11 https://learn.microsoft.com/ru-ru/teamblog/msdn-technet-migration

12 https://en.wikipedia.org/wiki/Microsoft_TechNet

13 https://github.com/MicrosoftArchive

14 https://stackoverflow.com/

15 https://stackexchange.com/sites

16 https://unix.stackexchange.com/

17 https://askubuntu.com/

18 https://networkengineering.stackexchange.com/

19 https://lwn.net/

20 https://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_10

21 https://en.wikipedia.org/wiki/X/Open_Transport_Interface

22 http://onreader.mdl.ru/CephCookbook/content/MellanoxOFED.html

23 https://docs.nvidia.com/networking/display/rdmaawareprogrammingv17/virtual+protocol+interconnect%C2%AE+(vpi)

24 https://pubs.opengroup.org/onlinepubs/000095399/xrat/xbd_chap04.html

25 https://github.com/dermesser/libsocket

26 https://github.com/fpagliughi/sockpp

Глава 2. Адресация

Мне просто нужно было взять гипертекст и объединить его с идеями TCP и DNS, и — та-дам! — Всемирная паутина готова.

Тим Бернерс-Ли, «Answers for Young People», 1989

Введение

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

Также разберемся с некоторыми особенностями работы поверх IPv6. Узнаем о Dual Stack и о том, как он помогает в переходе на IPv6.

Мы рассмотрим суть доменных имен и способы их преобразования в IP-адреса с помощью системы DNS и узнаем о нескольких функциях, которые позволяют тонко настроить работу DNS.

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

Адреса и доменные имена

Обычно пользователь вводит IP-адрес как строку в командном интерфейсе или внутри программы. Например: "192.168.2.1". В том же формате он ожидает увидеть программный вывод, например, в протоколе ошибок. Но на практике IP-адрес — это число, хранимое в структуре. И функции сокетного API требуют подстановки этой структуры.

Чтобы преобразовывать IP-адрес из строковой формы в форму, понятную остальному API, и обратно, существует набор функций. Кроме того, хотя реальные сетевые интерфейсы в интернете адресуются по IP, хорошо написанная программа наравне с адресами принимает доменные имена, такие как www.google.com.

В общем случае, чтобы преобразовать доменное имя в адрес, необходимо выполнить следующее:

• Проверить статически заданные преобразования в файле hosts. Процесс обработки настраивается отдельно. В Linux, например, в /etc/host.conf.

• Если в hosts записи нет, выбрать работоспособный DNS-сервер из сконфигурированных в ОС.

• Обратиться к DNS-серверу с запросом.

Это достаточно сложный процесс, но он полностью реализован в API и вызывается функцией getaddrinfo() и ее аналогами.

Файл hosts в Unix-подобных системах расположен по пути /etc/hosts, а в ОС Windows — по пути %SystemRoot%\system32\drivers\etc\hosts.

Переменная %SystemRoot% обычно имеет значение c:\windows.

В этой главе мы рассмотрим большинство функций для работы с IP-адресами и доменными именами.

Структуры адресов

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

// Заголовок только для POSIX, в Windows это ws2def.h.

#include <sys/socket.h>

// Структура адреса, общая для всех.

struct sockaddr

{

// Семейство адресов, AF_xxx.

unsigned short sa_family;

// 14 байт адреса, для TCP/IP – адрес и порт.    char sa_data[14];

};

Но обычно удобнее использовать структуры, специфичные для протокола. Например, для IPv4 структуры адреса таковы:

// Заголовок только для POSIX, в Windows это ws2def.h.

#include <netinet/in.h>

// Только для Internet-протокола IPv4 ("_in").

// struct sockaddr_in6 для IPv6 будет рассмотрена позже.

struct sockaddr_in

{

// Семейство адресов, AF_INET.

short int sin_family;

// Номер порта в сетевом порядке байтов.

unsigned short int sin_port;

// Internet address.

struct in_addr sin_addr;

// Заполнение для соответствия размеру struct sockaddr.

unsigned char sin_zero[8];

};

// Internet address. Исторически представляет собой структуру.

struct in_addr

{

// 32-битный int, то есть байты IPv4.

uint32_t s_addr;

};

Адрес для UNIX-сокетов, которые мы рассмотрим в следующей главе, представляет собой путь в файловой системе. Для сокетов типа AF_PACKET, используемых в Linux для работы с канальным уровнем, адрес содержит индекс устройства.

Устройство структур показано на рис. 2.1.

Рис. 2.1. Различные типы адресов

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

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

{

const int port { std::stoi(argv[1]) };

// Аналогично int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP).

socket_wrapper::Socket sock = {AF_INET, SOCK_DGRAM, IPPROTO_UDP};

// Ручное заполнение структуры адреса.

sockaddr_in addr =

{

// Тип адреса.

.sin_family = PF_INET,

// Порт в сетевом порядке байтов.

.sin_port = htons(port),

};

// Адрес 0.0.0.0.

addr.sin_addr.s_addr = INADDR_ANY;

std::cout

<< "Starting echo server on the port "

<< port << "...\n";

// Вызов bind(), для которого структуру требуется преобразовать

// в общий тип.

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

sizeof(addr)) != 0)

{

return EXIT_FAILURE;

}

...

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

Адрес — это число, поэтому сформировать его и заполнить структуру можно даже так:

// Адрес 192.168.1.2.

uint8_t ip0 = 192;

uint8_t ip1 = 168;

uint8_t ip2 = 1;

uint8_t ip3 = 2;

unsigned short port = 12345;

// Итоговое число.

uint32_t destination_address = (ip0 << 24) | (ip1 << 16) | (ip2 << 8) | ip3;

sockaddr_in address;

address.sin_family = AF_INET;

// Преобразуем в нужный порядок байтов.

address.sin_addr.s_addr = htonl(destination_address);

address.sin_port = htons(destination_port);

Это пример плохого стиля, и так делать неправильно. Для работы с адресами существует набор функций — используйте их, а не битовые операции с внутренней структурой адресов.

В Python все адреса представлены обычными типами Python, такими как int. Классы для адресов отсутствуют.

IPv6

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

Адрес IPv6 содержит 16 октетов против всего четырех в IPv4. Если отображать такие адреса в десятичной нотации с точками, он будет сложно читаемым и очень длинным. Поэтому его печатным отображением служат шестнадцатеричные числа, разделенные двоеточием, с возможными пропусками компонентов адреса.

Протокол IPv4 до сих пор широко применяется, в том числе по следующим причинам:

• Большинство оборудования настроено и проверялось на IPv4.

• Адреса IPv6 тяжело читать, несмотря на «совместимость» формата с IPv4.

• IPv4 «и так работает». А зачем менять то, что работает?

Что произошло с IPv5? Такой протокол действительно существовал, но он представлял собой протокол для работы с потоковым аудио поверх IP, более известный как Internet Stream Protocol 2, или ST-2.

О поддержке IPv6 в современных ОС, как правило, можно не волноваться: везде поддержка уже включена. Например, Linux поддерживает IPv6, начиная с версии ядра 2.2. CPython для проверки этого факта имеет константу socket.has_ipv6, сейчас безусловно установленную в True.

При необходимости можно проверить наличие атрибута IPPROTO_IPV6 у модуля socket  hasattr(_socket, 'IPPROTO_IPV6').

Так как IPv6 — протокол сетевого уровня, переход с IPv4 не сильно повлияет на особенности работы сокетов. Чтобы понять, что изменилось, стоит обратиться к man 7 ipv6.

Там говорится следующее:

• Добавились новые константы: пространство адресов AF_INET6.

• Протокол имеет несколько своих опций, что логично.

• Изменилась структура адреса, что следует из описания протокола.

Структура адреса для IPv6 выглядит так:

struct sockaddr_in6

{

// AF_INET6.

sa_family_t sin6_family;

// Номер порта.

in_port_t sin6_port;

// Метка потока IPv6.

// То есть пакетов от источника к назначению,

// отправляемых с конкретной целью.

// Обрабатывается на маршрутизаторах специальным образом.

uint32_t sin6_flowinfo;

// Адрес IPv6.

struct in6_addr sin6_addr;

// Scope ID.

uint32_t sin6_scope_id;

// В Windows поле заменяется следующим объединением:

// union

// {

//     ULONG    sin6_scope_id;

//     SCOPE_ID sin6_scope_struct;

// };

};

Атрибут scope_id — это идентификатор топологической области, в которой IPv6-адрес может использоваться в качестве уникального идентификатора интерфейса либо набора интерфейсов. Адрес может иметь локальную или глобальную область действия.

Поскольку несколько сетевых интерфейсов могут иметь одинаковый IPv6-адрес, scope_id может идентифицировать сетевой интерфейс.

Некоторые дополнительные структуры:

struct in6_addr

{

// Адрес IPv6.

unsigned char s6_addr[16];

};

// Только для Windows:

typedef struct

{

union

{

struct

{

// Зона области администрирования.

ULONG Zone : 28;

ULONG Level : 4;

};

ULONG Value;

};

} SCOPE_ID, *PSCOPE_ID;

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

В Python адрес IPv6 представляет собой кортеж следующего вида:

tuple(host, port, flowinfo, scope_id)

где flowinfo и scope_id аналогичны членам sin6_flowinfo и sin6_scope_id описанной выше структуры sockaddr_in6. При использовании большинства функций и классов модуля socket эти параметры можно опустить в целях обратной совместимости.

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

В IPv4 тоже существует проблема выбора интерфейса среди имеющих одинаковые адреса. Но как она должна решаться, не специфицировано.

Общая структура адресов

Размер адреса по сравнению с IPv4 увеличен, и неясно, какой тип адреса использует клиентская сторона, поэтому потребовалась новая структура, которая может принять любой адрес в accept(). Эта структура называется sockaddr_storage и определяется следующим образом:

#include <sys/socket.h>

struct sockaddr_storage

{

// Семейство адресов.

sa_family_t ss_family;

// Выравнивание, зависящее от реализации.

char __ss_pad1[_SS_PAD1SIZE];

int64_t __ss_align;

char __ss_pad2[_SS_PAD2SIZE];

};

В Windows данная структура27 определена в файле ws2def.h, а на Windows Server 2003 и XP — в winsock2.h.

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

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

Привязка адреса к сокету

Сокет, созданный функцией socket(), идентифицируется только по домену, семейству протоколов и конкретному протоколу. Но для того, чтобы подключиться к сокету извне либо организовать подключение с использованием сокета, необходимо привязать к сокету адрес.

Привязка адреса в общем случае означает привязку не IP, а адреса, определяемого доменом. Например, для Unix-сокетов это путь к файлу.

Для TCP, SCTP и UDP-сокетов привязка адреса будет означать привязку:

• IP-адреса;

• порта.

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

Локальный адрес может быть привязан явно и неявно. В случае подключения к «серверу», например, адрес и порт будут назначены сокету автоматически. Но если сокет является прослушивающим или требуется привязать лишь один из возможных адресов, сокетное API предоставляет возможности для явной привязки адреса.

Функция bind()

Данная функция явно привязывает к сокету локальный адрес и объявлена в файле sys/socket.h.

Внимание! Функция bind() чаще всего применяется для привязки адреса и порта сервера. Для клиента используйте функцию connect(), описанную в главе 3, либо явное указание адреса в recvfrom() и sendto().

#include <sys/socket.h>

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

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

• socket — дескриптор сокета;

• address — указатель на адрес для привязки;

• address_len — размер структуры адреса: sizeof(sockaddr).

Возвращаемое значение — 0 при успехе либо –1 при неудаче, в ОС Windows — INVALID_SOCKET.

В случае ошибки bind() сокет может оставаться в разном состоянии. Например, в Linux версии ядра 2.4 и ниже сокет требовалось пересоздать. В новых версиях ядра Linux ошибка bind() не ведет к порче сокета, и поэтому на одном и том же сокете его можно вызывать многократно.

В других ОС поведение сокета после ошибки вызова bind() может отличаться.

Рассмотрим пример:

#if not defined(INVALID_SOCKET)

#    define INVALID_SOCKET (-1)

#endif

int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

if (INVALID_SOCKET == sock)

{

// Ошибка при создании.

std::cerr

<< sock_wrap.get_last_error_string()

<< std::endl;

return EXIT_FAILURE;

}

// Явное заполнение структуры адреса.

// Не делайте так!

// Подробнее см. далее.

sockaddr_in addr =

{

.sin_family = PF_INET,

.sin_port = htons(8080),

};

addr.sin_addr.s_addr = INADDR_ANY;

if (bind(sock, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) != 0)

{

// Требуется явно закрыть сокет, пусть даже ОС его сама закроет и удалит

// при выходе из программы.

// Это хороший стиль, который способствует созданию повторно

// используемого кода.

close(sock);

throw std::runtime_error("Bind error");

}

В Python данная функция является методом класса socket.socket:

import socket

try:

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,

socket.IPPROTO_UDP)

# Привязать адрес, в данном случае IP+порт.

s.bind(('127.0.0.1', 8080))

print(s)

except Exception as e:

# Если адрес уже используется, может возникнуть исключение.

print(f'Socket error: {e}')

raise

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

<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=17, laddr=('127.0.0.1', 8080)>

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

Конвертация адреса в читаемую форму и обратно

За конвертацию отвечают функции, объявленные в файле arpa/inet.h в ОС Linux либо в файлах ws2tcpip.h и wsipv6ok.h в ОС Windows.

Функция inet_pton() конвертирует адрес из читаемой формы в sockaddr. Название функции расшифровывается как inet Printable TO Numeric. Обратная ей функция inet_ntop() конвертирует адрес в читаемую форму. Название функции расшифровывается как inet Numeric TO Printable.

#include <arpa/inet.h>

int inet_pton(int af, const char *src, void *dst);

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

Параметры функций inet_:

• af — семейство адресов. Поддерживаются AF_INET и AF_INET6.

• src — источник, содержащий адрес. Для первой функции — строка. Для второй — указатель на структуру in_addr для IP либо in6_addr для IPv6.

• dst — указатель на приемник. Тип — либо указатель на структуру, либо строка.

• size — размер приемника в байтах.

Внимание! Функции inet_ к DNS не обращаются и доменные имена разрешать не могут. Они просто выполняют конвертацию между строковым представлением и структурой адреса.

Функция inet_pton() возвращает 1 в случае успеха, 0, если адрес некорректный, и –1, если указано некорректное семейство адресов.

Функция inet_ntop() возвращает указатель на строку в случае успеха или нулевой указатель в случае неудачи.

Внимание! Вам также могут встретиться другие функции, такие как inet_makeaddr(). Они устарели, могут быть непотокобезопасными и не работают с IPv6. Использовать их не рекомендуется.

Устаревшие и нестандартные функции

Некоторые из устаревших функций:

• char* inet_ntoa(in_addr in) — преобразовывает IPv4-адрес в строку.

• int inet_aton(const char *cp, in_addr *inp) — преобразовывает строку в адрес и сохраняет в inp.

• in_addr_t inet_addr(const char *cp) — преобразовывает строку в числовой адрес в сетевом порядке байтов.

• in_addr_t inet_network(const char *cp) — преобразовывает строку адреса сети в числовой адрес.

• in_addr inet_makeaddr(in_addr_t net, in_addr_t host) — формирует из адреса узла и сети структуру.

• in_addr_t inet_lnaof(in_addr in) — получает локальный адрес узла.

• in_addr_t inet_netof(in_addr in) — получает адрес сети узла.

Кроме них существуют нестандартные функции:

• int inet_net_pton(int af, const char *pres, void netp, size_t nsize) — преобразовывает номер сети в число из строки в число.

• char *inet_net_ntop(int af, const void netp, int bits, char pres, size_t psize) — преобразовывает номер сети в строку.

Эти функции работают с IPv6, но, например, в ОС Windows они не встречаются, и лучше воздержаться от их использования.

Пример:

// IPv4.

sockaddr_in sa;

// IPv6.

sockaddr_in6 sa6;

// IPv4.

inet_pton(AF_INET, "10.12.110.57", &(sa.sin_addr));

// IPv6.

inet_pton(AF_INET6, "2001:db8:63b3:1::3490", &(sa6.sin6_addr));

// Буфер для строкового представления IPv4.

std::array<char, INET_ADDRSTRLEN> ip4;

// Сконвертировать адрес в строку типа char*.

inet_ntop(AF_INET, &(sa.sin_addr), ip4.data(), ip4.size());

// IPv6:

// Буфер для строкового представления IPv6.

std::array<char, INET6_ADDRSTRLEN> ip6;

inet_ntop(AF_INET6, &(sa6.sin6_addr), ip6.data(), ip6.size());

std::cout

<< "IP6 Address: " << ip6.data()

<< ", IP4 Address " << ip4.data()

<< std::endl;

Результат:

➭ build/bin/b01-ch02-ip-convert

IP6 Address: 2001:db8:63b3:1::3490, IP4 Address 10.12.110.57

В Python есть аналогичные функции:

ip4 = socket.inet_pton(socket.AF_INET, '10.12.110.57')

ip6 = socket.inet_pton(socket.AF_INET6, '2001:db8:63b3:1::3490')

print(socket.inet_ntop(socket.AF_INET, ip4))

print(socket.inet_ntop(socket.AF_INET6, ip6))

Результат их вызова такой же:

➭ src/book01/ch02/python/ip-convert.py

10.12.110.57

2001:db8:63b3:1::3490

Модуль ipaddress в Python

Стандартная библиотека Python содержит модуль ipaddress, предназначенный для работы с IP-адресами и адресами сетей.

Данный модуль не обращается к сети, а манипулирует исключительно представлением IP-адресов.

Его функции и классы позволяют:

• сравнивать IP-адреса;

• производить с адресами арифметические действия, например складывать;

• представлять IPv4- и IPv6-адреса в различных форматах;

• определять, находятся ли два хоста в одной и той же подсети;

• определять, подходит ли данный адрес указанному интерфейсу;

• перебирать все узлы в конкретной подсети;

• проверять, является ли строка допустимым IP-адресом.

Модуль содержит несколько основных классов:

• IPv4Address и IPv6Address — представление IP-адреса.

• IPv4Network и IPv6Network — подсеть.

• IPv4Interface и IPv6Interface — представление адресов сетевого интерфейса.

За их создание отвечают следующие функции:

# Вернуть IP-адрес, как IPv4, так и IPv6.

def ip_address(address) -> IPv4Address | IPv6Address

# Создать подсеть из адресов, обычно задаваемую адресом и маской подсети.

# Если параметр strict равен True, установленные биты хоста вызовут

# исключение ValueError, иначе просто будут замаскированы.

def ip_network(address, strict=True) -> IPv4Network | IPv6Network

# Создать объект интерфейса.

def ip_interface(address) -> IPv4Interface | IPv6Interface

Для всех функций параметр address — строка в формате IPv4/IPv6 или число, представляющее адрес.

Классы адресов имеют такие свойства, как:

• Определение типа адреса:

• is_multicast — адрес является адресом групповой передачи. Например, 10.10.1.255.

• is_private — внутренний адрес для локальных сетей. Адресов из этого диапазона нет в интернете. Например, 192.168.1.1 или 10.10.1.1.

• is_global — адрес из диапазона адресов, распределенных для интернета. Например, 8.8.8.8.

• is_unspecified — неопределенный адрес. Пример: 0.0.0.0.

• is_reserved — IP-адрес, зарезервированный IANA. Например, 192.168.0.0/24, зарезервированный для специальных целей.

• is_loopback — адрес является локальной петлей (loopback). Это все адреса, начинающиеся со 127, типичный пример — 127.0.0.1.

• is_link_local — IPv4-адрес зарезервирован для локального использования.

• is_site_local — IPv6-адрес зарезервирован для локального использования.

• ipv4_mapped — IPv6-адреса, отображенные на пространство IPv4, начинающиеся с :FFFF/96.

• Представления адреса в разном виде:

• compressed — сокращенная форма представления IPv6-адреса.

• exploded — полная форма представления IPv6-адреса.

• packed — бинарная форма представления адреса: 4 байта для IPv4 или 16 байт для IPv6.

• reverse_pointer — имя обратной DNS PTR-записи для IP-адреса.

В современных IP-сетях адреса не делятся на классы, то есть используется бесклассовая адресация, или CIDR. Поэтому сеть или подсеть обозначаются как адрес и маска, например: 192.168.1.0/24 или 192.168.1.0/255.255.255.0.

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

Данной маске соответствует каждый второй узел сети из 28 – 1 узел. Но с такими масками обычно не работают, и пользоваться такой записью не стоит, чтобы не получать непонятных ошибок.

Помимо методов определения типа сети и получения адреса в разном представлении, аналогично классам IP адресов, классы IPv4Network и IPv6Network позволяют:

• Выделить различные типы адресов:

• network_address — адрес сети, который соответствует битам маски.

• broadcast_address — широковещательный адрес для заданного адреса сети.

• hostmask — маска адреса узла данной сети, например, как IP-адрес 0.0.0.255.

• netmask — сетевая маска, как IP-адрес. Например, 255.255.255.0.

• Получить различные части сети:

• hosts() — список хостов.

• subnets() — список подсетей.

• supernet() — надсеть.

• prefix_len() — число битов маски сети.

• Выполнить сравнение:

• subnet_of(), supernet_of() — проверка на то, является ли сеть подсетью или надсетью указанной сети.

• compare_networks() — выполняет сравнение двух сетей, возвращая 0, –1 или 1.

Объекты интерфейсов представляют собой объединение адреса и сети: интерфейс находится в сети, в которой он имеет адрес.

Кроме того, модуль содержит несколько отдельных полезных функций:

# Получить адрес в виде 4 упакованных байт в сетевом порядке.

def v4_int_to_packed(address)

# Получить адрес в виде 16 упакованных байт в сетевом порядке.

def v6_int_to_packed(address)

# Получить итератор диапазона адресов от первого и до последнего адреса.

def summarize_address_range(first, last)

# Возвращает итератор свернутых объектов (адрес и маску)

# IPv4Network или IPv6Network.

def collapse_addresses(addresses)

# Возвращает ключ, подходящий для сортировки между сетями и адресами.

def get_mixed_type_key(obj)

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

Существуют библиотеки с более широкими возможностями, например библиотека Netaddr28. Но она не входит в стандартную библиотеку Python, и ее придется устанавливать отдельно.

Доменные имена

Доменное имя — это имя, которое идентифицирует области иерархии, являющиеся единицами административной автономии в сети интернет (см., например, доменное имя mail.google.com на рис. 2.2).

Рис. 2.2. Доменный адрес

Такие области называются доменами. По определению, приведенному выше, управляет доменом и несет ответственность за содержимое, связанное с ним, отдельное лицо или организация. За имя и содержимое google.com несет ответственность корпорация Google LLC.

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

Связывание доменов и серверов организации выполняется по IP-адресу. При этом одному доменному имени может соответствовать множество IP-адресов, что используется, например, при распределении нагрузки.

Общее пространство имен интернета функционирует благодаря системе доменных имен, или DNS, представляющей собой иерархию серверов. Чаще всего DNS используется для преобразования имен в адреса и обратно, но также может быть использована для получения различных служб, относящихся к домену, например e-mail сервера.

Каждый сервер, отвечающий за имя, может передать ответственность за дальнейшую часть домена другому серверу. Для взаимодействия серверов в RFC 1035 Domain names — implementation and specification описан протокол работы с DNS.

Сервера хранят метаданные об узлах либо адреса серверов, которые могут предоставить эти метаданные. Они хранятся как типизированные записи, имеющие предопределенные ключи (в том числе как записи, отображающие доменные имена на адреса либо делегирующие такое отображение другим серверам в иерархии).

Например:

• A или address-запись хранит адрес IPv4.

• AAAA-запись обычно хранит IPv6-адрес.

• CNAME содержит привязку поддомена к каноническому имени домена.

• MX-запись содержит IP-адрес сервера электронной почты домена.

• NS-запись указывает иные DNS-сервера домена.

Компания — регистратор доменных имен вносит на DNS-сервера запись о соответствии домена IP-адресам.

Получим данные домена yandex.ru:

➭ whois yandex.ru

% TCI Whois Service. Terms of use:

% https://tcinet.ru/documents/whois_ru_rf.pdf (in Russian)

% https://tcinet.ru/documents/whois_su.pdf (in Russian)

domain:        YANDEX.RU

nserver:       ns1.yandex.ru. 213.180.193.1, 2a02:6b8::1

nserver:       ns2.yandex.ru. 93.158.134.1, 2a02:6b8:0:1::1

state:         REGISTERED, DELEGATED, VERIFIED

org:           YANDEX, LLC.

taxpayer-id:   7736207543

registrar:     RU-CENTER-RU

admin-contact: https://www.nic.ru/whois

created:       1997-09-23T09:45:07Z

paid-till:     2024-09-30T21:00:00Z

free-date:     2024-11-01

source:        TCI

Last updated on 2024-06-06T20:06:30Z

Это домен 2-го уровня, который зарегистрирован регистратором Ru-Center. В данном случае указаны адреса DNS-серверов компании Yandex, что позволяет компании самостоятельно управлять отображением имени домена на конкретные серверы. Регистратор, как правило, вносит записи о доменах 2-го уровня иерархии.

В отдельных случаях регистрацией может заниматься любая организация, которая уже имеет домен. Новые домены будут принадлежать данной организации и являться поддоменами основного домена, например: nas.cloudns.cc — домен 3-го уровня, который зарегистрирован сервисом CloudNS, предоставляющим услуги связывания динамических IP с доменом. Часто такие домены называются доменными зонами, как и домены 1-го уровня, например «доменная зона ru».

Доменные имена состоят из меток либо имен, заканчивающихся точками, и читаются справа налево. Корневая доменная зона, или «домен уровня 0», имеет пустую метку, в результате остается лишь точка.

FQDN — Fully Qualified Domain Name, или полное доменное имя. Это имя, которое определено в иерархии, полностью однозначно. В FQDN указаны все части доменной иерархии, ни одна не пропускается. Например, имя одного из серверов имен Yandex: "ns1.yandex.ru".

Обратите внимание на точку в конце — это домен 0-го уровня. Домен 1-го уровня — зона «ru», домен 2-го уровня — «yandex». Доменное имя сервера имен является доменом 3-го уровня. www.yandex.ru и yandex.ru — разные домены разных уровней.

Согласно RFC 1035 существуют ограничения на размер доменного имени:

• Не более 253 символов на имя, включая точки.

• Не более 63 символов на метку.

• Не более 127 уровней иерархии при использовании односимвольных меток.

Во внутреннем двоичном представлении DNS для хранения имени максимальной длины требуется 255 октетов. Но DNS также хранит длину имени. В результате полное доменное имя может иметь длину не более 253 символов в текстовом представлении.

Функции для работы с DNS

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

Кроме того, некоторые соответствия адресов именам могут динамически изменяться. Так что обычно узлу приходится обращаться к DNS, чтобы получить IP-адрес по имени домена, как показано на рис. 2.3.

Рис. 2.3. Работа с доменами в браузере

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

База узлов и работа с ней

Рассмотрим несколько простых функций для получения IP-адреса по имени узла и наоборот — имени узла по адресу. Эти функции обращаются к DNS. Как и некоторые другие функции, они используют структуру hostent, определенную в netdb.h:

struct hostent

{

// Официальное имя узла.

// При использовании DNS или аналогичной системы разрешения имен

// полное доменное имя – FQDN.

char *h_name;

// Список псевдонимов, которые отдает DNS-сервер, если они были прописаны

// в записях ALIAS и CNAME. Завершается nullptr.

char **h_aliases;

// Тип адреса или семейство адресов: AF_INET, AF_INET6.

int h_addrtype;

// Длина адреса.

int h_length;

// Список адресов в сетевом порядке байтов. Завершается nullptr.

char **h_addr_list;

}

В данном случае в псевдонимы попадают дополнительные имена, которые связаны с данным именем. Записи типа ALIAS и CNAME создают перенаправление на другое имя домена.

Например, если имеется домен www.google.com, на DNS-сервере для него может существовать единственная запись:

«www.google.com IN CNAME google.com».

Эта запись говорит клиенту, что нужно прочитать записи домена google.com для имени домена www.google.com. В этих записях указано все необходимое, в частности адреса и DNS-сервера.

Записи CNAME нельзя добавлять на домены 2-го уровня. Но записи типа ALIAS можно. Преобразование для ALIAS-записей производится на сервере.

И те и другие записи добавляются в список h_aliases.

Кроме этих типов записей, существуют редко используемые типы ANAME, BNAME и DNAME.

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

hostent *gethostent(void);

Нулевой указатель означает, что записей больше нет. Предварительно необходимо «открыть базу», используя функцию sethostent().

Эти функции получают сведения из базы данных узлов через Name Service Switch — интерфейс GLibC, предоставляющий доступ к различным базам данных настроек узла.

Существуют функции, которые предоставляют доступ к базе служб, RPC, сетевых адаптеров, протоколов, сетей и т.п. За подробностями обращайтесь к man 1 getent.

Согласно RFC 7766 «DNS Transport over TCP»29, для работы с DNS можно использовать TCP. Функции могут включать данный режим между вызовами gethostby...:

// Открыть базу хостов и установить в начало указатель.

// Если stayopen true — использовать постоянное TCP-соединение

// для работы с DNS сервером.

// В ином случае запрос DNS будет выполняться, используя UDP-дейтаграммы.

// Если использован gethostent, значение stayopen должно быть true.

void sethostent(int stayopen);

// Закрыть базу хостов.

// При вызове TCP соединение с сервером DNS будет закрыто.

void endhostent(void);

Пример:

#include <netdb.h>

...

// В какой-то функции:

hostent *he;

sethostent(1);

while ((he = gethostent()) != nullptr)

{

std::cout << he->h_name << std::endl;

}

endhostent();

...

При запуске этого кода на узле увидим примерно следующее:

➭ build/bin/b01-ch02-net-db

localhost.localdomain

localhost.localdomain

office-machine.comp.ru

Функции, которые отвечают за открытие и закрытие «базы хостов», также влияют на поведение gethostbyaddr() и gethostbyname(), описанных ниже.

В Python аналога данным функциям нет. В Go, например, функция проброшена из библиотеки C.

Получение списка адресов с помощью getaddrinfo()

Обычно для высокоуровневой работы с DNS используют следующие функции:

• getaddrinfo() — получает список адресов, выделяя под него память.

• getnameinfo() — получает список имен узлов.

• freeaddrinfo() — освобождает память.

• gai_strerror() — переводит коды ошибок в читаемые строки.

Основная функция getaddrinfo() объединяет действия устаревших функций getipnodebyname(), getipnodebyaddr(), getservbyname() и getservbyport() в одном интерфейсе.

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

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

Поведение функции getaddrinfo() можно настраивать. В Linux настройки содержатся в файле /etc/gai.conf, во FreeBSD их можно посмотреть командой ip6addrctl show.

По умолчанию порядок возврата адресов зависит от используемой ОС и может быть такой:

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

2. Предпочитать соответствие области.

3. Избегать устаревших адресов.

4. Предпочитать домашние адреса.

5. Предпочитать соответствие метке.

6. Предпочитать более высокий приоритет.

7. Предпочитать собственный транспорт.

8. Предпочитать меньшую область.

9. Использовать самый длинный префикс соответствия. Сравнивается длина только в одном семействе адресов.

10. Оставить порядок без изменений.

Эта функция позволяет не заполнять вручную структуры sockaddr, sockaddr_in и подобные.

В Linux для использования этих функций необходимо включить файлы sys/types.h, sys/socket.h, netdb.h, а в Windows — файл ws2tcpip.h.

Прототипы:

#include <netdb.h>

int getaddrinfo(const char *node, const char *service,

const addrinfo *hints,

addrinfo **res);

void freeaddrinfo(struct addrinfo *res);

const char *gai_strerror(int errcode);

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

• node — доменное имя узла.

• service — имя службы или порт, если установлен соответствующий флаг.

• hints — структура, которая содержит «подсказки», например адреса и флаги.

• res — список, который содержит адреса, возвращенные getaddrinfo().

Рассмотрим структуру addrinfo, которая передается как параметр hints и возвращается как результат в res:

struct addrinfo

{

// AI_PASSIVE, AI_CANONNAME и т.д.

int ai_flags;

// AF_INET, AF_INET6, AF_UNSPEC.

int ai_family;

// SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, SOCK_RDM, SOCK_SEQPACKET.

int ai_socktype;

// 0 для "any" или IPPROTO_TCP, IPPROTO_UDP и пр.

int ai_protocol;

// Размер ai_addr в байтах.

size_t ai_addrlen;

// struct sockaddr_in или _in6.

struct sockaddr *ai_addr;

// Полное каноническое имя.

char *ai_canonname;

// Следующий узел списка.

struct addrinfo *ai_next;

};

Внимание! В ОС Windows два последних атрибута структуры меняются местами: сначала идет ai_canonname, а потом ai_addr.

В C-коде модуля socket для Python данная структура определена явно в файле Modules/addrinfo.h30, чтобы не зависеть от платформы: она будет использоваться, если в ОС по какой-то причине ее определение недоступно либо некорректно.

Порядок получения адресов из данной структуры показан на рис. 2.4.

Рис. 2.4. Структура addrinfo

В Unix-подобных ОС флаги для getaddrinfo(), передаваемые в поле ai_flags параметра hints, определены в netdb.h и могут быть следующими:

• AI_PASSIVE — адрес сокета предназначен для bind(), то есть адрес можно не указывать. Будет подставлен корректный адрес, вероятно 0.0.0.0, позволяющий запустить сервер, принимающий входящие подключения со всех интерфейсов.

• AI_CANONNAME — запросить каноническое имя.

• AI_NUMERICHOST — использовать числовой адрес хоста в качестве имени. Если флаг установлен, node должно содержать адрес в числовой форме, с точками для IPv4, а не домен.

• AI_NUMERICSERV — задать службу портом, а не именем. Если флаг установлен и поле service ненулевое, оно должно содержать строку с номером порта, то есть его строковое представление, в противном случае — название службы.

• AI_V4MAPPED — если IPv6-адреса не найдены, запросить IPv4-адреса и вернуть их вызывающей стороне как IPv4-отображенные IPv6-адреса, например ::FFFF:129.144.52.38.

• AI_ALL — запросить адреса IPv4 и IPv6. Причем IPv4 запрашивается как при установке флага AI_V4MAPPED.

• AI_ADDRCONFIG — запрашивать IPv4-адреса только в том случае, если система имеет хотя бы один IPv4-адрес; запрашивать адреса IPv6 только в том случае, если система имеет хотя бы один IPv6-адрес.

Начиная с GLibC 2.3.4, появились флаги для работы с доменными именами, не использующими стандарт ASCII:

• AI_IDN — если входное имя содержит символы, отличные от ASCII, используется кодирование IDN. См. RFC 3490 Internationalizing Domain Names in Applications. Исходная кодировка соответствует текущей локали.

• AI_CANONIDN — преобразовать каноническое имя в удобочитаемую форму. Если возвращенное каноническое имя закодировано с помощью ACE, оно будет содержать префикс xn-- для одного или нескольких компонентов имени. Данный флаг используется для преобразования компонентов.

• AI_IDN_ALLOW_UNASSIGNED — установка этих флагов активирует IDNA_ALLOW_UNASSIGNED — разрешить неназначенные кодовые точки Unicode.

• AI_IDN_USE_STD3_ASCII_RULES — проверить выходные данные, чтобы убедиться, что это имя хоста соответствует стандарту STD3, для использования в обработке IDNA — IDNA_USE_STD3_ASCII_RULES.

Функция возвращает 0 в случае успеха или ненулевой код ошибки в ином случае, что выгодно отличает ее от большинства функций, ошибка в которых устанавливается в отдельной переменной. Значения кодов указаны в man 3 getaddrinfo.

Передав возвращенный код в функцию gai_strerror(), можно получить текст ошибки.

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

// Функция freeaddrinfo() будет вызвана при разрушении указателя.

// Она нужна, чтобы освободить связный список.

typedef std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> AddrInfoResult;

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

Функция вернет значение типа:

// Порт – строка.

// Тип сокета, например SOCK_STREAM.

AddrInfoResult get_serv_info(const std::string &port, int sock_type)

{

// Структура "подсказок" – настроек, которые будет использовать

// getaddrinfo().

addrinfo hints =

{

// Флаги: вернуть серверу нужный адрес

// и флаг, указывающий, что служба задана не именем, а номером порта.

.ai_flags = AI_PASSIVE | AI_NUMERICSERV,

// IPv4. Если не важно, IPv4 или IPv6, указать AF_UNSPEC.

.ai_family = AF_INET,

.ai_socktype = sock_type,

// Протокол.

.ai_protocol = (sock_type == SOCK_STREAM ? IPPROTO_TCP : IPPROTO_UDP)

};

// Указатель на результаты.

addrinfo *s_i = nullptr;

if (int ai_status = getaddrinfo(nullptr, port.c_str(), &hints, &s_i);

ai_status != 0)

{

// Обратите внимание: для вывода ошибки используется gai_strerror().

throw std::logic_error(gai_strerror(ai_status));

}

// s_i теперь указывает на связный список

// из одной или более структур addrinfo.

return AddrInfoResult(s_i, freeaddrinfo);

}

Параметр service, который в примере задается портом, определяет службу. Если бы мы не использовали флаг AI_NUMERICSERV, он также мог быть задан именем службы, например «https». Соответствие «имя-порт» задано в /etc/services.

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

Если же служба задана некорректно, например недоступна для указанного протокола, функция вернет ошибку EAI_SERVICE.

Пример для клиента:

AddrInfoResult get_client_info(const std::string &host,

const std::string &port, int sock_type,

int sock_family)

{

addrinfo hints =

{

// Разница во флагах: AI_PASSIVE отсутствует, флаг возврата

// канонического имени не обязателен.

.ai_flags = AI_CANONNAME | AI_NUMERICSERV,

// Семейство адресов, например AF_UNSPEC для IPv4 и IPv6.

.ai_family = sock_family,

// Тип сокета.

.ai_socktype = sock_type,

// Протокол.

.ai_protocol = (sock_type == SOCK_STREAM ? IPPROTO_TCP : IPPROTO_UDP)

};

// Указатель на результаты.

addrinfo *c_i = nullptr;

// Для клиента нужно указать имя хоста, к которому подключаемся.

if (int ai_status = getaddrinfo(host.c_str(), port.c_str(), &hints, &c_i);

ai_status != 0)

{

throw std::logic_error(gai_strerror(ai_status));

}

// c_i теперь указывает на связный список из одной или более

// структур addrinfo.

// std::make_unique здесь не используется,

// потому что нужен пользовательский deleter.

return AddrInfoResult(c_i, freeaddrinfo);

}

Часто из списка просто берут первый адрес. Но можно пройти по списку и найти адрес, соответствующий нужным критериям, например:

• Любой работающий — проверяется выполнением подключения.

• С минимальным временем ответа — проверяется отправкой PING-запроса на адреса ICMP с расчетом времени ответа.

• Ближайший географически по базе GeoIP, находимый без запроса.

Внимание! Не все узлы будут отвечать на ping, но это не значит, что они не работают. При проверках это необходимо учитывать, иначе работающий адрес можно пропустить.

В Python данная функция вернет список из кортежей, содержащих:

• Семейство адресов.

• Тип сокета.

• Протокол.

• Каноническое имя.

• (Адрес, Порт) для IPv4 или (Адрес, Порт, flow info, scope id) для IPv6.

В случае неудачи функция выбросит исключение socket.gaierror или другие, например TypeError:

addrs = socket.getaddrinfo('www.google.com', '3490',

family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)

Очевидно, что в Python уже не требуется вызывать freeaddrinfo(). Также обратите внимание, что в данную функцию порт можно передать как строку, а в случае передачи числа не требуется его явное преобразование в сетевой порядок байтов.

Порт 3490 зарезервирован для Colubris Wireless Management System:

➭ grep 3490 /etc/services

colubris         3490/tcp

colubris         3490/udp

Мы выбрали его для примера случайным образом. Очевидно, что мы не используем систему Colubris, поэтому логичнее писать число, чтобы не вводить читателя кода в заблуждение.

Если ничего передавать не требуется, в Python вместо nullptr используется None.

Преобразование адресов в имена

Иногда требуется по IP-адресу получить имя соответствующего ему узла. Для такого преобразования существует функция getnameinfo():

#include <sys/socket.h>

#include <netdb.h>

int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,

char *host, socklen_t hostlen,

char *serv, socklen_t servlen, int flags);

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

• addr — исходный адрес, по которому будет получено имя.

• addrlen — размер структуры адреса.

• host — буфер для помещения имени узла. Может иметь значение nullptr, если имя узла получать не требуется.

• hostlen — длина буфера host. Если буфер имеет нулевое значение, длина тоже должна быть равна нулю.

• serv — буфер для помещения имени службы. Может иметь значение nullptr, если имя службы получать не требуется.

• servlen — длина буфера serv. Как и для hostlen, если буфер имеет нулевое значение, длина тоже должна быть равна нулю.

• flags — флаги для более тонкой настройки параметров возвращаемого значения. Например, позволяют вернуть строку чисел IP-адреса из структуры addr.

Внимание! Размер буферов для узла и службы должен быть достаточным, чтобы сохранить имя с завершающим нулевым символом.

Флаги для getnameinfo() определены в netdb.h:

• NI_NOFQDN — для локальных узлов вернуть только имя узла от полного доменного имени.

• NI_NUMERICHOST — вернуть числовую форму адреса узла вместо его имени.

• NI_NAMEREQD — вернуть ошибку, если имя узла не может быть найдено.

• NI_NUMERICSERV — вместо имени службы использовать числовую форму адреса службы.

• NI_NUMERICSCOPE — для IPv6-адресов вместо имени вернуть числовую форму идентификатора области.

• NI_DGRAM — указывает, что служба является службой дейтаграмм — SOCK_DGRAM: по умолчанию считается, что служба потоковая — SOCK_STREAM.

Для getnameinfo() существуют интернациональные флаги, аналогичные флагам для getaddrinfo(): NI_IDN, NI_IDN_ALLOW_UNASSIGNED и NI_IDN_USE_STD3_ASCII_RULES.

Подробнее см. в документации к своей ОС.

Возвращаемое значение функции аналогично тому, что возвращает getaddrinfo().

Пример:

sockaddr *addr;

socklen_t addrlen;

std::array<char, NI_MAXHOST> hbuf;

if (getnameinfo(addr, addrlen, hbuf.data(), hbuf.size(), nullptr, 0,

NI_NAMEREQD))

{

std::cerr << "could not resolve hostname" << std::endl;

}

else

std::cout << "host" << hbuf << std::endl;

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

print(socket.getnameinfo(('8.8.8.8', 80), socket.NI_NAMEREQD))

print(socket.getnameinfo(('8.8.8.8', 8080), socket.NI_NAMEREQD))

Результат:

('dns.google', 'http')

('dns.google', 'http-alt')

Прочие функции

В работе вам могут пригодиться следующие функции:

#include <unistd.h>

// Получить имя текущего хоста.

int gethostname(char *name, size_t namelen);

// Установить имя текущего хоста.

int sethostname(const char *name, size_t namelen);

// Получить уникальный 32-битный идентификатор машины.

long gethostid(void);

// Установить идентификатор.

int sethostid(long hostid);

Параметры функций:

• name — имя. Массив символов, 0 в конце не обязателен.

• namelen — длина имени.

• hostid — идентификатор узла.

Очевидно, что set-функции будут работать только с правами суперпользователя или с привилегией администратора.

В Python функций для получения id нет, есть только функции для работы с именем узла:

def sethostname(name: str) -> None

def gethostname() -> str

В Python есть полезная функция socket.getfqdn(), которая принимает единственный аргумент name и возвращает FQDN. Данная функция сначала проверяет имя хоста, возвращаемое функцией gethostbyaddr(), а затем его псевдонимы. Если полное доменное имя недоступно и параметр name задан, он вернется без изменений. Для пустого имени либо адреса 0.0.0.0 будет вызван gethostname():

import socket

# localhost.localdomain

print(socket.getfqdn())

# a904c694c05102f30.awsglobalaccelerator.com

print(socket.getfqdn('unknowndomain.com'))

# yandex.ru

print(socket.getfqdn('yandex.ru'))

# localhost.localdomain

print(socket.getfqdn('0.0.0.0'))

# dns.google

print(socket.getfqdn('8.8.8.8'))

Результат вызова примера будет следующим:

➭ src/book01/ch02/python/fqdn_example.py

localhost.localdomain

a904c694c05102f30.awsglobalaccelerator.com

yandex.ru

localhost.localdomain

dns.google

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

#include <netdb.h>

// Будет содержать номер ошибки, если функции ниже завершатся некорректно.

// Возможные ошибки: HOST_NOT_FOUND, NO_DATA, NO_RECOVERY, TRY_AGAIN.

extern int h_errno;

// Получить список hostent по имени хоста.

struct hostent *gethostbyname(const char *name);

// AF_INET.

#include <sys/socket.h>

// Получить hostent по адресу хоста.

struct hostent *gethostbyaddr(const void *addr, socklen_t len, int type);

В Python существует функция socket.gethostbyname_ex(), которая вернет кортеж, содержащий:

• Официальное имя хоста.

• Список псевдонимов, то есть содержимое DNS-записей CNAME.

• Список IPv4-адресов.

def gethostbyname_ex(host) -> Tuple(name, aliaslist, addresslist)

Пример:

>>> print(socket.gethostbyname_ex('yandex.ru'))

('yandex.ru', [], ['77.88.55.66', '5.255.255.55', '5.255.255.50', '77.88.55.70'])

Внимание! Эти функции в C API устарели. Не используйте их.

Тонкая работа с DNS

Внимание! Этот раздел, скорее всего, вам не пригодится, особенно на базовом уровне. Его можно пропустить или прочесть позже. На понимание материала это не повлияет.

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

Функции типа res_*() и функции dn_comp(), dn_expand() отвечают за прямую работу с резолвером. Они выполняют запросы и обрабатывают ответы, полученные от серверов доменных имен. Данные функции не кросс-платформенные. Подробнее о них можно прочесть в man 3 resolver.

Для гибкого выполнения запросов к DNS существует утилита dig — domain information groper. То, как используются приведенные ниже функции и флаги, можно посмотреть в ее коде.

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

#include <netinet/in.h>

#include <arpa/nameser.h>

#include <resolv.h>

struct __res_state;

typedef __res_state *res_state;

Инициализация

Функции res_ninit() и устаревшая res_init() нужны для инициализации DNS и должны вызываться первыми. Их вызов, а также последующий вызов функций res_nquery() выделяет память. Поэтому для ее освобождения требуется вызвать res_nclose():

int res_ninit(res_state statep);

void res_nclose(res_state statep);

Обратите внимание, что res_state — указатель на структуру и по нему в структуру будет производиться запись.

Алгоритм работы функций следующий:

1. Прочитать файлы настройки из файла /etc/resolv.conf для получения имени домена по умолчанию и адресов серверов имен.

2. Если серверы не заданы, используется локальный узел.

3. Если не задан домен, используется домен из переменной окружения LOCALDOMAIN или домен локального узла.

Они возвращают 0 в случае успеха и –1 при ошибке.

Внимание! На каждый вызов res_ninit() должен быть сделан вызов res_nclose().

В Linux структура __resstate определена в /usr/include/bits/types/res_state.h и достаточно сложна. Она содержит различные параметры, массив серверов имен, компоненты домена, которые используются для поиска, и т.д.

Поля, которые могут быть интересны пользователю:

• retrans — интервал повторной передачи.

• retry — число попыток повторной передачи.

• options — флаги, описанные ниже.

• pfcode — флаги RES_PRF_. Используются в программе dig.

• ipv6_unavail — флаг неуспешности подключения к IPv6-серверу.

Константы опций и флагов pfcode определены в /usr/include/resolv.h.

Настройки отладки и безопасности:

• RES_DEBUG — печатать отладочные сообщения. Этот параметр доступен, только если GLibC собрана с включенной отладкой, которая по умолчанию выключена.

• RES_INSECURE1 — принимать ответ от ошибочного сервера. Может использоваться для обнаружения потенциальных угроз безопасности, но требует перекомпиляции GLibC с включенной отладкой и установки флага RES_DEBUG.

• RES_INSECURE2 — принимать ответ, содержащий некорректный запрос. Требует перекомпиляции GLibC с включенной отладкой.

• RES_USE_DNSSEC — использовать DNSSEC с битом OK в записи OPT. Это значение подразумевает установленный RES_USE_EDNS0.

• RES_USE_EDNS0 — включить поддержку расширений DNS EDNS0, описанных в RFC 2671 «Extension Mechanisms for DNS (EDNS0)».

Настройки запроса:

• RES_IGNTC — не пытаться повторить запрос с помощью TCP.

• RES_SNGLKUPREOP — открывать для каждого запроса новый сокет, если указано значение RES_SNGLKUP.

• RES_STAYOPEN — используется вместе с RES_USEVC для поддержания одного TCP-соединения для запросов между ответами.

• RES_USEVC — использовать TCP-соединение для запросов вместо дейтаграмм UDP.

• RES_SNGLKUP — выполнять запросы IPv6 и IPv4 последовательно. По умолчанию GLibC, начиная с версии 2.9, выполняет поиск по IPv4 и IPv6 параллельно. Некоторые приложения DNS-серверов не могут обработать такие запросы должным образом и делают паузу между ответами на запрос. Включение флага замедлит процесс определения имени.

Настройки поиска:

• RES_DEFNAMES — res_nsearch() будет добавлять имя домена по умолчанию к именам с одним компонентом в имени, то есть не содержащим точек.

• RES_DNSRCH — если указан, res_search() будет искать имена узлов в текущем и родительском домене.

• RES_INIT — произведена инициализация. True, если уже вызывалась функция res_ninit().

• RES_NOALIASES — отключить использование переменной окружения HOSTALIASES.

• RES_NOTLDQUERY — не искать неполное имя как домен верхнего уровня — TLD, Top Level Domain.

• RES_RECURSE — установить в запросах бит рекурсии. Рекурсия выполняется сервером доменных имен, а не функцией res_nsend(). Параметр включен по умолчанию.

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

Комбинации флагов:

• RES_DEFAULT — комбинация флагов: RES_RECURSE, RES_DEFNAMES, RES_DNSRCH и RES_NOIP6DOTINT.

Устаревшие опции res_ninit()

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

• RES_NOCHECKNAME — выключить в современном BIND проверку недопустимых символов в поступающих именах узлов и почтовых именах, таких как символы подчеркивания «_», не-ASCII или управляющие символы.

• RES_USE_INET6 — пытаться выполнить запрос AAAA раньше запроса A внутри функции gethostbyname() и отображать ответы IPv4 в «туннелированной форме» IPv6, если записи AAAA не были обнаружены, но есть запись типа A. Новые приложения должны использовать getaddrinfo(), а не gethostbyname().

• RES_PRIMARY — запрашивать только первичный сервер имен.

• RES_AAONLY — принимать только достоверные ответы. Функция res_send() продолжит работать, пока не найдет достоверный ответ или вернет ошибку.

• RES_KEEPTSIG — не обрезать записи TSIG.

• RES_BLAST — посылать каждый запрос одновременно и рекурсивно всем серверам.

• RES_USEBSTRING — выполнить поиск обратной записи IPv6 с помощью формата значимых битов, описанного в RFC 2673. По умолчанию параметр не задан, поэтому используется полубайтовый формат.

• RES_NOIP6DOTINT — использовать зону ip6.arpa при поиске обратной записи IPv6 вместо ip6.int.

Флаги RES_PRF отвечают за получение и вывод утилитой dig различных секций запроса и ответа к DNS.

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

• RES_PRF_STATS — вывести статистику в конце ответа, например время на запрос, имя DNS-сервера и размер сообщения.

• RES_PRF_CLASS — вывести класс записи, например IN для Internet.

• RES_PRF_CMD — вывести командную строку запроса.

• RES_PRF_QUES — вывести секцию Question.

• RES_PRF_ANS — вывести секцию Answer.

• RES_PRF_TTLID — вывести значение TTLS в записях.

Включить опции в утилите dig можно следующим образом:

➭ dig ya.ru +qu

; <<>> DiG 9.18.13 <<>> ya.ru +qu

;; global options: +cmd

...

;; QUESTION SECTION:

;ya.ru.

Так как большинство флагов включено, более вероятно, что их придется отключать. Это делается через префикс «no»:

➭ dig ya.ru +nocl +nost +nocmd +noque +nohead

;; Got answer:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 393

;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:

; EDNS: version: 0, flags:; udp: 65494

;; ANSWER SECTION:

ya.ru.                  117     A       5.255.255.242

ya.ru.                  117     A       77.88.55.242

Рассмотрение данных опций выходит за рамки книги. Подробности см. в man 1 dig.

Отправка запросов

Запрос данных записей по доменному имени выполняет функция res_nquery():

int res_nquery(res_state statep, const char *dname,

int class, int type,

unsigned char *answer, int anslen);

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

• statep — состояние резолвера, инициализированное ранее через res_ninit().

• dname — доменное имя.

• class — класс пространства имен. Согласно RFC 1034 Domain Names — Concepts and facilities, это 16-битное значение, которое идентифицирует семейство протоколов или протокол. Например, IN — класс Internet.

• type — тип DNS-записи: A для адреса узла, CNAME — каноническое имя, NS — имя DNS-сервера домена и т.п.

• answer — буфер, в который будет помещен ответ.

• anslen — размер буфера answer в байтах.

В arpa/nameser.h определены следующие классы пространства имен:

// Значения для поля класса.

typedef enum __ns_class

{

// Cookie.

ns_c_invalid = 0,

// Internet.

ns_c_in = 1,

// Не поддерживается.

ns_c_2 = 2,

// MIT Chaos-net.

ns_c_chaos = 3,

// MIT Hesiod.

ns_c_hs = 4,

//

// Значения классов, не появляющиеся в ресурсных записях.

//

// Для предварительных разделов в запросах на обновление.

ns_c_none = 254,

// Любая запись, шаблон.

ns_c_any = 255,

ns_c_max = 65536

} ns_class;

Если вам когда-либо придется использовать функции из этого раздела, скорее всего, значение параметра class всегда будет ns_c_in.

Количество значений параметра type значительно больше. Среди них интересны прежде всего следующие:

• ns_t_a — запись с адресом IPv4.

• ns_t_aaaa — адрес IPv6.

• ns_t_cname — каноническое имя.

• ns_t_ns — адрес сервера, отвечающего за доменную зону.

• ns_t_mx — адрес почтового шлюза домена.

• ns_t_ptr — соответствие адреса имени. Требуется для обратного DNS-запроса.

• ns_t_soa — информация о доменной зоне. SOA-запись, которая содержит такие сведения, как адрес электронной почты администратора, дата обновления домена и т.д.

• ns_t_srv — указание на местоположение серверов для служб. Подробнее см. в RFC 2782 A DNS RR for specifying the location of services (DNS SRV).

• ns_t_txt — произвольные данные. Часто используется такими сервисами, как Let's Encrypt, для авторизации домена.

Функция res_nsearch() отправляет запрос и ждет ответа подобно res_nquery(), но при этом учитывает правила работы и поиска по умолчанию:

int res_nsearch(res_state statep,

const char *dname, int class, int type,

unsigned char *answer, int anslen);

Правила настраиваются через RES_DEFNAMES и RES_DNSRCH параметра options структуры, на которую указывает statep.

Функция res_nquerydomain() отправляет запрос с помощью res_nquery() с объединенными name и domain.

int res_nquerydomain(res_state statep,

const char *name, const char *domain,

int class, int type, unsigned char *answer,

int anslen);

Функция res_nmkquery() используется внутри res_nquery() и является процедурой низкого уровня. Она создает сообщение-запрос в buf длиной buflen для имени домена dname:

int res_nmkquery(res_state statep,

int op, const char *dname, int class,

int type, const unsigned char *data, int datalen,

const unsigned char *newrr,

unsigned char *buf, int buflen);

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

• statep — состояние резолвера.

• op — тип запроса:

• QUERY — стандартный запрос.

• IQUERY — обратный запрос. Удален в glibc 2.26, так как давно не поддерживался серверами DNS.

• NS_NOTIFY_OP — уведомить об изменении SOA (Start of Authority) вторичный сервер.

• dname — доменное имя.

• class — класс пространства имен.

• type — тип записи.

• data — данные, которые будут добавлены в запрос.

• datalen — размер данных в байтах.

• newrr — не используется.

• buf — буфер, в котором будет сформирован запрос.

• buflen — размер буфера в байтах.

Функция res_nsend() отправляет заранее созданный запрос, указанный в msg, длиной msglen и возвращает ответ в буфере answer длиной anslen. Вызывает функцию res_ninit(), если это еще не сделано.

int res_nsend(res_state statep,

const unsigned char *msg, int msglen,

unsigned char *answer, int anslen);

Сжатие и раскрытие имен

Функция dn_comp() сжимает имя домена. Сжатие достигается за счет использования указателей в тексте для повторяющихся строк или символов в доменном имени, то есть функция удаляет компоненты имени.

Функция dn_expand() раскрывает сжатое имя домена до полного доменного имени.

Сжатое имя может содержаться в запросе или ответном сообщении.

#include <resolv.h>

int dn_comp(const char *exp_dn, unsigned char *comp_dn,

int length, unsigned char **dnptrs,

unsigned char **lastdnptr);

int dn_expand(const unsigned char *msg,

const unsigned char *eomorig,

const unsigned char *comp_dn, char *exp_dn,

int length);

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

• exp_dn — имя домена.

• comp_dn — буфер для сжатого имени.

• length — длина буфера comp_dn.

• dnptrs — массив указателей на предварительно сжатые компоненты имени в текущем сообщении. Первый указатель содержит адрес начала сообщения. Список оканчивается нулевым указателем.

• lastdnptr — предел массива. Если dnptrs нулевой, имя домена не является сжатым.

Функция вставляет метки в сообщение по мере сжатия имени, но если список указателей на метки нулевой, функция не сжимает имя, а преобразует его из ASCII во внутренний формат. Если lastdnptr нулевой, список меток не обновляется.

Параметры dn_expand():

• msg — начало сообщения, содержащее сжатое имя.

• eomorig — указатель на первый адрес после сообщения; по сути, аналог метода end() в векторе C++.

• comp_dn — сжатое имя домена.

• exp_dn — буфер, в который будет сохранено полное имя.

• length — длина буфера exp_dn.

Функция возвращает размер сжатого имени либо –1 при ошибке.

Рассмотрим пример, в котором сначала вызовем функцию dn_comp() для сжатия имени:

int main()

{

// Буфер для сжатого имени.

std::array<unsigned char, 256> buf;

unsigned char *dnptrs = nullptr;

unsigned char *lastdnptr = nullptr;

// Сжать доменное имя.

if (auto result = dn_comp("www.google.com", buf.data(), buf.size(),

&dnptrs, &lastdnptr); result != -1)

{

std::cout << "Len = " << result << "\n"

<< std::string(buf.begin(), buf.begin() + result) << "\n"

<< "DP = "

<< (dnptrs ? reinterpret_cast<const char *>(dnptrs) : "nullptr")

<< "\n"

<< "LDP = "

<< (lastdnptr ? reinterpret_cast<const char *>(lastdnptr) :

"nullptr")

<< std::endl;

Затем вызовем функцию dn_expand() для его распаковки из сообщения и получения имени в обычном несжатом виде:

// Буфер для распакованного доменного имени.

std::array<char, 256> exp_buf;

// Получить доменное имя в exp_buf.

if ((result = dn_expand(buf.data(), buf.data() + result, buf.data(),

exp_buf.data(), exp_buf.size())) != -1)

{

std::cout

<< "Len = " << result << "\n"

<< std::string(exp_buf.begin(), exp_buf.begin() + result)

<< std::endl;

return EXIT_SUCCESS;

}

perror("dn_expand");

return EXIT_FAILURE;

}

perror("dn_comp");

return EXIT_FAILURE;

}

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

➭ build/bin/b01-ch02-dn-comp-expand

Len = 16

wwwgooglecom

DP = nullptr

LDP = nullptr

Len = 16

www.google.com

Видно, что размер буфера, который использовала функция dn_comp(), составляет 16 байт, тогда как размер выведенной строки — 12 байт. Функция использовала оставшиеся 4 байта для хранения служебной информации, по которой вызов функции dn_expand() восстановил доменное имя.

Устаревшие функции

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

Что они делают, понятно из описания функций выше:

extern struct __res_state _res;

int res_init(void);

int res_query(const char *dname, int class, int type,

unsigned char *answer, int anslen);

int res_search(const char *dname, int class, int type,

unsigned char *answer, int anslen);

int res_querydomain(const char *name, const char *domain,

int class, int type, unsigned char *answer,

int anslen);

int res_mkquery(int op, const char *dname, int class,

int type, const unsigned char *data, int datalen,

const unsigned char *newrr,

unsigned char *buf, int buflen);

int res_send(const unsigned char *msg, int msglen,

unsigned char *answer, int anslen);

Данные функции используют общую глобальную переменную, и поэтому они не являются реентерабельными и потокобезопасными.

Модуль encodings.idna в Python

Модуль реализует преобразование имен согласно RFC 3490 «Internationalizing Domain Names» in Applications и RFC 3491 «Nameprep: A Stringprep Profile for Internationalized Domain Names». Эти RFC описывают протокол для поддержки символов, отличных от ASCII, в доменных именах, то есть кодирование в Punycode и stringprep и обратно: преобразование в кодировку, совместимую с ASCII, — ASCII-compatible encoding, или ACE.

В модуле socket производится прозрачное преобразование имен узлов Unicode в ACE и обратно. Поэтому обычно приложениям не нужно беспокоиться о преобразовании имен.

Другие модули Python, работающие с сетью, также выполняют преобразования незаметно для пользователя. Но при получении имен узлов из сети, например, при обратном поиске имен, автоматическое преобразование в Unicode не выполняется, поэтому приложения, выполняющие подобные задачи, должны декодировать имена в Unicode.

Модуль encodings.idna содержит функции для выполнения данной задачи:

# Вернуть подготовленное имя.

def encodings.idna.nameprep(label)

# Вернуть значение, преобразованное в ASCII с помощью метода Punycode.

def encodings.idna.ToASCII(label)

# Вернуть значение, преобразованное в Unicode.

def encodings.idna.ToUnicode(label)

Рассмотрим примеры вызова функций:

>>> import encodings.idna

>>> encodings.idna.ToASCII('www.сайт.рф')

b'xn--www..-7ve7dxcfmy'

>>> encodings.idna.ToUnicode(b'xn--www..-7ve7dxcfmy')

'www.сайт.рф'

>>> encodings.idna.nameprep('www.сайт.рф')

'www.сайт.рф'

>>> encodings.idna.nameprep('www.Сайт.рф')

'www.сайт.рф'

Внимание! Если требуется использовать стандарт IDNA 2008 из «RFC 5891 Internationalized Domain Names in Applications (IDNA): Protocol» и RFC 5895 «Mapping Characters for Internationalized Domain Names in Applications (IDNA) 2008», пользуйтесь сторонним пакетом idna31.

Пример разрешения имени

Реализуем приложение, которое получает IP-адрес ресурса по его доменному имени.

Код резолвера

В коде ниже класс SocketWrapper позволяет получить строку ошибки. Показаны два варианта получения адреса:

• через getaddrinfo();

• через gethostbyname(), которая не работает с IPv6 и как в POSIX, так и в MSDN не рекомендована к использованию32.

Сначала реализуем первый вариант разрешения имен, используя getaddrinfo().

Получим список адресов:

int print_ips_with_getaddrinfo(const std::string &host_name)

{

// Для инициализации в ОС Windows.

socket_wrapper::SocketWrapper sock_wrap;

std::cout

<< "Getting name for \"" << host_name << "\"...\n"

<< "Using getaddrinfo() function." << std::endl;

addrinfo hints =

{

.ai_flags= AI_CANONNAME,

// Неважно, IPv4 или IPv6.

.ai_family = AF_UNSPEC,

// Потоковые сокеты, TCP-службы.

.ai_socktype = SOCK_STREAM,

// Любой протокол.

.ai_protocol = 0

};

// Результат.

addrinfo *s_i = nullptr;

if (int status = getaddrinfo(host_name.c_str(), nullptr, &hints, &s_i);

status != 0)

{

std::cerr

<< "getaddrinfo error: " << gai_strerror(status)

<< std::endl;

return EXIT_FAILURE;

}

// Нужно, чтобы автоматически освободить память.

const socket_wrapper::AddrInfoResult servinfo(s_i, freeaddrinfo);

Затем обойдем его в цикле:

for (auto const *s = servinfo; s != nullptr; s = s->ai_next)

{

std::cout << "Canonical name: ";

if (s->ai_canonname)

std::cout << s->ai_canonname;

std::cout << "\n";

assert(s->ai_family == s->ai_addr->sa_family);

std::cout << "Address type: ";

Выполним обработку IPv4-адресов:

if (AF_INET == s->ai_family)

{

// Это – адрес IPv4 длиной максимум INET_ADDRSTRLEN.

std::array<char, INET_ADDRSTRLEN> ip;

const sockaddr_in* const sin =

reinterpret_cast<const sockaddr_in* const>(s->ai_addr);

std::cout

<< "AF_INET\n"

<< "Address length: "

<< sizeof(sin->sin_addr) << "\n";

std::cout

<< "IP Address: "

<< inet_ntop(AF_INET, &(sin->sin_addr), ip.data(), ip.size())

<< "\n";

}

Аналогично обработаем адреса IPv6:

else if (AF_INET6 == s->ai_family)

{

// Это адрес IPv6.

std::array<char, INET6_ADDRSTRLEN> ip6;

// Для хранения адреса IPv6 используется sockaddr_in6.

sockaddr_in6 const * const sin =

reinterpret_cast<const sockaddr_in6* const>(s->ai_addr);

std::cout

<< "AF_INET6\n"

<< "Address length: " << sizeof(sin->sin6_addr) << "\n"

<< "IP Address: "

<< inet_ntop(AF_INET6, &(sin->sin6_addr), ip6.data(),

ip6.size())

<< "\n";

}

else

{

std::cout << s->ai_family << "\n";

}

std::cout << std::endl;

}

std::cout << std::endl;

freeaddrinfo(servinfo);

return EXIT_SUCCESS;

}

Заголовочные файлы не показаны, к тому же часть из них включена в библиотеку socket_wrapper.

И устаревший вариант, с использованием gethostbyname(). В старом коде он еще может встретиться.

Вызовем функцию и выполним проверку на ошибки:

// Устаревший вариант работает только для IPv4.

int print_ips_with_gethostbyname(const std::string &host_name)

{

socket_wrapper::SocketWrapper sock_wrap;

std::cout

<< "Getting name for \"" << host_name << "\"...\n"

<< "Using gethostbyname() function." << std::endl;

socket_wrapper::SocketWrapper sock_wrap;

const hostent *remote_host { gethostbyname(host_name.c_str()) };

if (nullptr == remote_host)

{

if (sock_wrap.get_last_error_code())

{

std::cerr

<< sock_wrap.get_last_error_string()

<< std::endl;

}

return EXIT_FAILURE;

}

std::cout

<< "Official name: " << remote_host->h_name << "\n";

Затем обработаем возвращенные адреса в цикле:

for (const char* const* p_alias =

const_cast<const char* const*>(remote_host->h_aliases);

*p_alias; ++p_alias)

{

std::cout

<< "# Alternate name: \"" <<  *p_alias

<< "\"\n";

}

std::cout << "Address type: ";

// Это проверять не обязательно.

if (AF_INET == remote_host->h_addrtype)

{

std::cout

<< "AF_INET\n";

<< "\nAddress length: "

<< remote_host->h_length << "\n";

in_addr addr = {0};

for (int i = 0; remote_host->h_addr_list[i]; ++i)

{

addr.s_addr = *reinterpret_cast<const u_long* const>(

remote_host->h_addr_list[i]);

std::cout << "IP Address: " << inet_ntoa(addr) << "\n";

}

}

Строку «AF_INET6» вы не увидите никогда, так как функция gethostbyname() работает только с IPv4-адресами:

// Событие ниже никогда не случится.

// gethostbyname() является устаревшей функцией.

// Она не работает с IPv6.

else if (AF_INET6 == remote_host->h_addrtype)

{

std::cout << "AF_INET6\n";

}

else

{

std::cout << remote_host->h_addrtype << "\n";

}

std::cout << std::endl;

return EXIT_SUCCESS;

}

Вызовем обе функции в main():

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

{

if (argc != 2)

{

std::cout << "Usage: " << argv[0] << " <hostname>" << std::endl;

return EXIT_FAILURE;

}

const std::string host_name = { argv[1] };

print_ips_with_getaddrinfo(host_name);

// Задержка в 10 мс, чтобы сервер отдавал примерно одинаковые списки

// адресов, не задействуя механизмы балансировки.

std::this_thread::sleep_for(std::chrono::milliseconds(10));

print_ips_with_gethostbyname(host_name);

return EXIT_SUCCESS;

}

Результат вызова:

➭ build/bin/b01-ch02-dns-resolver google.com

Getting name for "google.com"...

Using getaddrinfo() function.

Canonical name: google.com

Address type: AF_INET

Address length: 4

IP Address: 74.125.205.102

Canonical name:

Address type: AF_INET

Address length: 4

IP Address: 74.125.205.113

...

Canonical name:

Address type: AF_INET

Address length: 4

IP Address: 74.125.205.138

Canonical name:

Address type: AF_INET6

Address length: 16

IP Address: 2a00:1450:4010:c02::64

...

Canonical name:

Address type: AF_INET6

Address length: 16

IP Address: 2a00:1450:4010:c02::8a

Getting name for "google.com"...

Using gethostbyname() function.

Official name: google.com

Address type: AF_INET

Address length: 4

IP Address: 74.125.205.102

IP Address: 74.125.205.113

IP Address: 74.125.205.101

IP Address: 74.125.205.139

IP Address: 74.125.205.100

IP Address: 74.125.205.138

Первый вызов вернул не только IPv4, но и IPv6-адреса. Заметим, что если слишком часто делать запросы к google.com, результат будет отличаться, так как балансировщик нагрузки предлагает разные сервера. Чтобы этого избежать, в примере используется задержка в 10 мс.

Посмотрим, как реализовать резолвер на Python. Первый вариант — через getaddrinfo():

import os

import socket

import sys

import time

def print_ips_with_getaddrinfo(host_name: str):

print(f'Getting name for "{host_name}"...\nUsing getaddrinfo() function.')

# Любой тип адреса и протокола.

addrs = socket.getaddrinfo(host_name, None,

family=socket.AF_UNSPEC,

type=socket.SOCK_STREAM,

flags=socket.AI_CANONNAME, proto=0)

# ai_type, ai_proto не используются, но оставлены для наглядности.

for (ai_family, ai_type, ai_proto, ai_canonname, ai_addr) in addrs:

print(f'Canonical name: {ai_canonname}\n'

f'Address type: '

f'{str(ai_family).replace("AddressFamily.", "")}\n'

f'IP Address: {ai_addr[0]}')

И второй через gethostbyname():

def print_ips_with_gethostbyname(host_name: str):

print(f'Getting name for "{host_name}"...\n'

f'Using gethostbyname() function.')

# Аналог через hostbyname_ex()

official_name, alias_list, address_list = \

socket.gethostbyname_ex(host_name)

print(f'Official name: "{official_name}"')

for alias in alias_list:

print(f'Alias: "{alias}"')

for addr in address_list:

print(f'IP Address: "{addr}"')

Вызовем их:

if '__main__' == __name__:

if len(sys.argv) != 2:

print(f'Usage: {sys.argv[0]} <hostname>')

sys.exit(os.EX_USAGE)

print_ips_with_getaddrinfo(sys.argv[1])

# Задержка.

time.sleep(1)

print()

print_ips_with_gethostbyname(sys.argv[1])

sys.exit(os.EX_OK)

В результате запуска получим следующее:

➭ src/book01/ch02/python/dns-resolver.py google.com

Getting name for "google.com"...

Using getaddrinfo() function.

Canonical name: google.com

Address type: AF_INET

IP Address: 74.125.205.101

Canonical name:

Address type: AF_INET

IP Address: 74.125.205.113

Canonical name:

Address type: AF_INET

...

Address type: AF_INET

IP Address: 74.125.205.138

Canonical name:

Address type: AF_INET6

IP Address: 2a00:1450:4010:c0b::8a

Canonical name:

Address type: AF_INET6

IP Address: 2a00:1450:4010:c0b::64

Canonical name:

...

Getting name for "google.com"...

Using gethostbyname() function.

Official name: "google.com"

IP Address: "74.125.205.113"

...

IP Address: "74.125.205.138"

IP Address: "74.125.205.101"

Переход на IPv6

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

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

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

3. Изменить AF_INET на AF_INET6.

4. Изменить PF_INET на PF_INET6.

5. Изменить INADDR_ANY в присваиваниях на IN6ADDR_ANY.

6. Вместо sockaddr_in использовать sockaddr_in6. Там, где это необходимо, добавить «6» в имена полей.

7. Аналогичное sin_zero поле sin6_zero отсутствует, и обращения к первому необходимо удалить.

8. Вместо in_addr использовать in6_addr.

9. Вместо inet_aton() или inet_addr() использовать inet_pton().

10. Вместо inet_ntoa() использовать inet_ntop().

11. Вместо gethostbyname() использовать getaddrinfo().

12. Вместо gethostbyaddr() использовать функцию getnameinfo(), хотя gethostbyaddr() может работать с IPv6.

13. INADDR_BROADCAST больше не работает. Вместо него можно использовать многоадресную рассылку IPv6.

14. В bind() и везде, где непонятен тип и размер адреса, необходимо использовать sockaddr_storage.

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

Замена адреса выполняется следующим образом:

sockaddr_in sa;

sockaddr_in6 sa6;

// Любой IPv4-адрес (0.0.0.0). Или отсутствие привязки к сетевому интерфейсу.

sa.sin_addr.s_addr = INADDR_ANY;

// Аналог для IPv6-адреса.

sa6.sin6_addr = IN6ADDR_ANY;

При определении переменной типа in6_addr значение IN6ADDR_ANY_INIT можно использовать в качестве инициализатора:

in6_addr ia6 = IN6ADDR_ANY_INIT;

С точки зрения сокетов ничего больше не изменилось. Детали и подробности для более глубокого изучения описаны в RFC 5014 «IPv6 Socket API for Source Address Selection»33 и RFC 2133 «Basic Socket Interface Extensions for IPv6»34.

Совместное использование IPv6 и IPv4

Простая замена IPv4 на IPv6 приведет к тому, что приложение будет работать только с IPv6. А это часто не то, что требуется разработчику.

Поэтому многие ОС поддерживают Dual Stack, позволяющий обрабатывать пакеты IPv4 и IPv6 одновременно.

Стек протоколов с двойной поддержкой изображен на рис. 2.5.

Рис. 2.5. Двойной TCP/IP-стек

Модуль протокола сетевого уровня определяется по идентификатору протокола.

Проверить наличие поддержки двойного стека в Python можно через функцию has_dualstack_ipv6() модуля socket:

➭ python3 -c 'import socket; print(socket.has_dualstack_ipv6())'

True

Данная функция реализована через установку опции IPV6_V6ONLY в 0 и проверку результата. По умолчанию опция установлена в 0, что означает включение двойного стека, то есть сокет будет принимать как IPv4-, так и IPv6-подключения. В ином случае IPv6-сокет будет работать только с IPv6.

В Linux значение опции по умолчанию берется из файла /proc/sys/net/ipv6/bindv6only.

Убедимся, что если функция вернула True, следующий код будет работать как с IPv4, так и с IPv6:

import socket

with socket.socket(

socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP

) as sock:

sock.bind(('::', 12345, 0, 0))

print(f'Listening socket = {sock}, '

f'dual stack = {socket.has_dualstack_ipv6()}\n')

while True:

s, addr = sock.accept()

print(f'Client socket = {s}\nClient address = {addr}')

while data := s.recv(15):

print(data)

Теперь отправим данные с IPv6-сокета, а затем с IPv4-сокета:

import socket

# Отправить пакет через IPv6-сокет.

sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

sock.sendto(('::', 12345), b'test ipv6')

sock.close()

# Отправить пакет через IPv4-сокет.

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

sock.sendto(('127.0.01', 12345), b'test ipv4')

sock.close()

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

Сервер принимает данные, отправленные клиентом выше:

➭ src/book01/ch02/python/dualstack-server.py

Listening socket = <socket.socket fd=3, family=10, type=2, proto=17, laddr=('::', 12345, 0, 0)>, dual stack = True

(b'test ipv6', ('::1', 50457, 0, 0))

(b'test ipv4', ('::ffff:127.0.0.1', 48376, 0, 0))

С первым клиентом все ясно: ::1 — адрес IPv6 локального узла.

Для второго клиента сервер видит адрес IPv6, хотя клиент использует IPv4: '::ffff:127.0.0.1'. Это происходит потому, что на сервере был создан IPv6-сокет.

Формат адреса вида "::FFFF:<IPv4 address>" используется в IPv6 для отображения пространства IPv4-адресов в IPv6.

Для IPv6 также можно использовать рассматриваемые в главе 5 функции create_server() и create_connection() примерно следующим образом:

import socket

addr = ('', 12345)

if socket.has_dualstack_ipv6():

s = socket.create_server(addr, family=socket.AF_INET6,

dualstack_ipv6=True)

else:

s = socket.create_server(addr)

Очевидно, что вышесказанное относится и к C++. Обмен данными через IPv6-сокеты работает так же, как и через сокеты IPv4, так как протоколы и абстракции, лежащие уровнем выше, во многом одинаковы.

Резюме

Обратиться к сокету извне можно по адресу, привязать который позволяет функция bind().

Пользователи часто оперируют разными форматами IP-адресов. Например, IPv4-адрес обычно выглядит как "192.168.2.1". В этом формате пользователь как вводит адреса, так и ожидает увидеть их в выводе, например в журнале ошибок.

Но IP-адрес — это число, которое может быть представлено структурой. Для преобразования IP-адресов в разные форматы существует набор функций.

Для конвертации адреса из читаемой формы в sockaddr используется функция inet_pton(), а для конвертации из sockaddr в читаемую форму — inet_ntop().

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

Хорошо написанные приложения, удобные и понятные для пользователей, кроме IP-адресов могут обрабатывать доменные имена, такие как www.google.com.

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

За DNS-сервер, как правило, отвечает организация.

Для работы с базой узлов с помощью DNS предназначены функции gethostent() и sethostent(). Для получения списка адресов по имени используется функция getaddrinfo(). Для получения списка имен по адресу — getnameinfo().

Чтобы изменить порядок работы с DNS или тонко настроить его работу, существует целый набор функций типа res_*() и функции dn_comp(), dn_expand(). Они выполняют запросы и обрабатывают ответы, полученные от серверов доменных имен.

В Python существует модуль encodings.idna, который реализует преобразование имен.

Из-за быстрого роста количества сетевых устройств интернет переходит на протокол IPv6, у которого огромное пространство адресов. Но IPv4 до сих пор популярен, поэтому многие ОС поддерживают Dual Stack для обработки пакетов IPv4 и IPv6 одновременно. Двойной стек предоставляет возможность плавного перехода от IPv4 к IPv6.

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

1. Какая структура является общей для различных форматов адреса?

2. Что представляет собой структура, хранящая IPv4-адрес? Какие у нее атрибуты?

3. Чем отличается IPv6-адрес? Какие у него атрибуты?

4. Для чего нужна структура sockaddr_storage?

5. Как правильно заполнить структуру IP-адреса?

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

7. Допустимо ли использовать функцию bind() для клиента?

8. Как преобразовать IP-адрес в печатную форму? Как преобразовать строку адреса в структуру?

9. Что будет, если в функцию inet_pton() передать доменное имя?

10. Допустимо ли использовать функцию inet_ntoa() вместо inet_ntop()? В каких условиях и почему?

11. Какой модуль в Python используется для работы с IP-адресами? Как узнать, что адрес принадлежит интернет-диапазону? Покажите на примере, как из адреса по маске извлечь адрес подсети.

12. Что такое доменное имя? Для чего оно нужно?

13. Какая система и как поддерживает работу с доменными именами?

14. Может ли одному доменному имени соответствовать несколько IP-адресов и почему?

15. Возможна ли обратная ситуация, когда одному IP-адресу соответствует множество доменных имен?

16. Для чего предназначены функции gethostent() и sethostent()?

17. Как преобразовать доменное имя в IP-адрес?

18. Какая функция используется для получения списка доменных имен по IP-адресу? В чем особенности ее работы?

19. Как обработать ситуацию, когда функция getaddrinfo() не может разрешить доменное имя?

20. В каких случаях может потребоваться изменение порядка работы с DNS или его настройка?

21. Какие функции используются для выполнения запросов к DNS? А для обработки ответов?

22. В каком модуле Python реализовано преобразование доменных имен?

23. Какие задачи выполняет сторонний пакет Python idna?

24. Что такое Dual Stack?

25. Каким образом проводится миграция сети с IPv4 на IPv6 при использовании Dual Stack?

26. Какой тип адресации используется для узлов, работающих в режиме Dual Stack?

27. Какие проблемы могут возникнуть при работе с Dual Stack IPv4 и IPv6 на устройствах IoT?

28. С помощью написанной ранее функции получения списка адресов по имени проведите анализ доступности популярных веб-сайтов, например google.com, facebook.com. Используйте инструменты командной строки, такие как nslookup и dig, для сравнения результатов, получаемых вашей программой, со стандартными методами определения адресации.

29. Добавьте в клиент возможность адресовать сервер как по адресам IPv4 и IPv6, так и по доменному имени.

27 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms740504(v=vs.85)

28 https://github.com/netaddr/netaddr

29 https://datatracker.ietf.org/doc/html/rfc7766

30 https://github.com/python/cpython/blob/main/Modules/addrinfo.h#L127

31 https://pypi.org/project/idna/

32 https://learn.microsoft.com/ru-ru/windows/win32/api/wsipv6ok/nf-wsipv6ok-gethostbyname

33 https://datatracker.ietf.org/doc/html/rfc5014

34 https://datatracker.ietf.org/doc/html/rfc2133

Глава 3. Схема работы сети в ОС и в разных языках. UNIX и дейтаграммные сокеты

Это философия Unix: пишите программы, которые делают что-то одно, но делают хорошо. Пишите программы, которые будут работать вместе. Пишите программы для обработки текстовых потоков, потому что это универсальный интерфейс.

Doug McIlroy, «The Art of Unix Programming: Basics of the Unix Philosophy», 2003

Введение

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

В качестве ОС для рассмотрения сетевого стека возьмем Windows, QNX и Linux. Понимание структуры и механизмов работы сетевого стека позволяет пользоваться им более осмысленно и реализовывать более качественные приложения.

Затем перейдем к рассмотрению сокетных API, которые различаются на разных платформах. Так, между BSD- и POSIX-сокетами разница небольшая, а между WinSock 2 API в ОС Windows и POSIX — значительная, хотя WinSock 2 и содержит набор POSIX-совместимых функций.

На примере языка Go посмотрим, как сокетный API может быть реализован в языках, отличных от C и C++.

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

Закончим главу изучением сокетов домена Unix и дейтаграммных сокетов домена Inet, попутно затронув UDP. На примере этого протокола мы рассмотрим концепцию портов, узнаем о типах портов и функциях для работы с портами.

Схема работы сети в ОС

Рис. 3.1. Компоненты сетевой подсистемы

Хотя в разных ОС сетевая подсистема организована по-разному, в ней существуют компоненты, общие для большинства реализаций. Они изображены на рис. 3.1:

• Библиотека сокетов, реализованная либо отдельно, как в Windows, либо в составе LibC, как в Linux. Эту библиотеку можно использовать из языков C и C++ напрямую. А трансляторы и библиотеки высокоуровневых языков программирования связываются с ней и используют ее функции.

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

• Модули протоколов, реализующие конкретные протоколы. Они могут быть доступны не всегда: например, регистрироваться в процессе загрузки ядра ОС или загружаться по мере необходимости.

• Набор драйверов устройств. Разные сетевые карты и прочие устройства требуют разных способов взаимодействия с ними. Это взаимодействие обеспечивают драйверы, предоставляя высокоуровневым компонентам абстракцию устройства.

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

Библиотека выполняет проверки и преобразования, и некоторые функции, например такие, как htons(), могут быть реализованы целиком в библиотеке.

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

Например, если приложение отправляет данные, используя функцию send(), по TCP/IP через Ethernet, выполняется примерно следующий процесс:

1. Приложение формирует данные в буфере в формате протокола прикладного уровня.

2. Затем приложение осуществляет вызов библиотечной функции send(). Ей предоставляется дескриптор сокета, буфер с данными и его размер.

3. В результате выполняется системный вызов, например write() в Linux, который в ядре перенаправляется сетевому стеку.

4. Данные копируются в буфер ядра.

5. Сетевой стек, зная, что обмен производится через TCP/IP-сокет, определяет модуль, функции которого требуется вызвать, и вызывает функции передачи данных сокета.

6. Данные последовательно разбиваются на части, инкапсулируются сначала в TCP-сегменты, а затем — в IP-пакет.

7. Так как IP-адрес связан с известным устройством, стек определяет, какому драйверу и с каким идентификатором устройства передавать данные. В Linux со структурой буфера сокета в ядре уже связана структура драйвера, поэтому нужен не поиск, а просто вызов по адресу.

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

9. Сетевой адаптер отправляет данные в среду передачи.

На принимающей стороне процесс будет обратным:

1. Приложение вызовет библиотечную функцию recv(), которой предоставлен дескриптор сокета, буфер под данные и максимальный размер буфера.

2. Далее будет выполнен системный вызов, который в ядре перенаправляется сетевому стеку.

3. Поскольку данных нет, вызов будет заблокирован в ожидании.

4. Адаптер увидит преамбулу к данным в среде, прочитает из среды передачи часть данных, сверит физический адрес в данных и свой адрес, скопирует данные в свой внутренний буфер или напрямую в память компьютера и вызовет прерывание по завершении чтения.

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

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

7. Сетевому стеку будет отправлен сигнал.

8. По типу протокола стек увидит, что это IP-пакет, сверит адрес, идентификатор фрагмента и контрольную сумму. Если адрес и содержимое остальных полей корректны, продолжит обработку.

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

10. Приложение из библиотечной функции получит данные и размер принятых данных.

В реальности обработка гораздо сложнее.

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

Во-вторых, существуют разные типы сетевых узлов. Например, роутеры, которые выполняют маршрутизацию и переадресацию.

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

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

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

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

Пример сетевой подсистемы: Windows

В ОС Windows прикладной разработчик обычно вызывает функции WinSock API — интерфейса библиотеки сокетов. Пример организации стека в ОС Windows показан на рис. 3.2.

Функции send() и recv() в ОС Windows вызывают функции WSASend() и WSARecv() соответственно.

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

Сокетный провайдер для обращения к ядру и работы с сетевым стеком на уровне ядра использует разные интерфейсы. Ранее в ОС Windows это был TDI — Transport Driver Interface, и для обмена данными между драйвером и приложением использовался IRP — I/O Request Packet. На данный момент TDI устарел. Теперь он называется NetIO Legacy TDI и поддерживается драйвером TDX.

Рис. 3.2. Пример организации стека в ОС Windows

Верхнеуровневые библиотеки могут работать с ядром через интерфейс ядра WinSock Kernel Network Programming Interface — WSK NPI, или просто WSK.

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

WSK NPI неверно называть «WinSock уровня ядра». Это новый интерфейс с асинхронным вводом-выводом, использующий функции IRP и обратные вызовы событий для повышения производительности.

WSK NPI поддерживает операции сокета, такие как создание, привязка адреса, подключение и обмен данными.

Драйвер AFD — Ancillary Function Driver for WinSock — также обеспечивает функциональность сокетов для приложений, использующих протоколы TCP/IP. AFD отвечает за соединения и управление буфером. Он работает с драйверами протоколов низкого уровня. Они реализуют интерфейс NDIS — Network Driver Interface Specification. Когда данные принимаются, они сначала копируются в буфер AFD. Затем, когда приложение вызывает WSARecv(), они копируются провайдером из буфера драйвера AFD в буфер приложения.

Обмен данными контролирует WFP — платформа фильтрации.

Более подробная схема поддержки сети в ОС Windows показана на рис. 3.3. На этой схеме мы видим, что приложение может как напрямую использовать сокеты через WinSock, так и работать с API высокого уровня, предоставляемыми разными библиотеками: WinHTTP, WinInet, библиотеками для работы с инфраструктурой P2P и т.п. Все эти библиотеки реализованы поверх сокетов. В свою очередь, сокеты — это только API, вызывающий функции сервисного интерфейса — SPI, реализаций которого на прикладном уровне может быть несколько.

Рис. 3.3. Взаимодействие компонентов стека ОС Windows

Предоставляют эту реализацию провайдеры двух типов:

• Провайдеры имен — отвечают за разрешение имен, преобразование между именами и адресами.

• Провайдеры транспорта — непосредственно выполняют обмен данными.

Раньше были доступны цепочки провайдеров — многоуровневые провайдеры, или Layered Services Providers. Они позволяли, например, подключать фильтры содержимого, перед тем как данные будут обработаны высокоуровневыми функциями и приложением. Этим активно пользовались антивирусы. Но с появлением WFP данная функциональность устарела.

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

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

Провайдеры взаимодействуют с сетевым стеком ядра. Они делают запросы к драйверу AFD.sys. Этот драйвер вызывает функции реализации сетевого стека для используемого протокола.

Стеки протоколов реализованы в отдельных драйверах. Например, стек TCP/IP реализован в драйвере TCPIP.sys.

DNS, как ни странно, реализован в драйвере HTTP.sys.

WFP напоминает BPF и iptables из Linux. Именно поверх WFP работает брандмауэр Windows.

NDIS абстрагирует сетевое оборудование от сетевых драйверов более высокого уровня. Он задает стандартный интерфейс между сетевым драйвером в стеке, вызывая абстрактные драйверы, которые управляют оборудованием. Также он хранит настройки и сведения о состоянии сетевых драйверов: указатели на функции, дескрипторы, блоки параметров и т.д.

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

Подробнее работу с сетью в ОС Windows мы рассмотрим в главах 14–20.

Пример сетевой подсистемы: QNX

Рассмотрим, как организована сетевая подсистема в ОС QNX. В ее основе лежит многопоточный редиректор и мультиплексор пакетов io-pkt, а сетевая подсистема включает его и набор подключаемых библиотек, которые реализуют драйверы устройств и протоколов.

QNX — микроядерная операционная система реального времени, предназначенная в основном для встраиваемых систем. Она POSIX-совместима и является одной из наиболее распространенных микроядерных ОС.

Ядро в таких операционных системах реализует только минимальный набор функций.

Схема организации сетевой подсистемы представлена на рис. 3.4.

Все процессы, обрабатывающие сетевые протоколы, работают через io-pkt, который загружает драйверы и модули, указанные пользователем, и организует между ними обмен данными.

Сетевые протоколы, например TCP или Qnet, реализованы в отдельных модулях. Драйверы — тоже модули, которые можно загружать и выгружать динамически.

Рис. 3.4. Схема сетевой подсистемы QNX

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

Сетевая подсистема QNX позволяет иметь несколько объектов протокола. Например, загружать несколько экземпляров TCP/IP-стека, которые работают через один и тот же физический интерфейс. Это дает возможность создавать виртуальные сети и легко поддерживать независимые пространства имен.

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

На рисунке 3.4 видно, что сетевые приложения работают поверх библиотеки сокетов libsocket. Библиотека сокетов преобразует интерфейс передачи сообщений в стандартный POSIX API. Возможна и работа напрямую через библиотеку, реализующую определенный протокол в пространстве пользователя.

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

Рис. 3.5. Модули сетевой подсистемы QNX

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

Модули сетевой подсистемы QNX показаны на рис. 3.5. Помимо указанных компонентов, в сетевом стеке, так же как и во многих Unix-подобных системах, есть пакетный фильтр BPF, который может запускать пользовательские обработчики.

В Linux и Windows реализованы более классические варианты.

Пример сетевой подсистемы: Linux

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

Упрощенный пример ее показан на рис. 3.6. Как и в любой другой ОС, пользователю система предоставляет сокетный интерфейс — функции API в LibC. На рисунке показаны несколько функций для создания соединения, его завершения и обмена данными. Подробнее мы их рассмотрим далее.

Рис. 3.6. Сетевая подсистема Linux

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

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

Вызовы, которые используют разные протоколы, адресуются сетевому стеку. Начинается все с создания сокета и структур в ядре, которые с ним связаны. Об этом мы подробнее поговорим в главе 7.

Для протоколов, ориентированных на соединение, например TCP, при создании подключения в ядре формируются структуры, которые хранят параметры этого подключения: номер последовательности TCP, номера портов, адреса пиров — удаленных узлов и т.п.

Передаваемые данные попадают в модули протоколов, которые формируют готовый PDU нижнего уровня.

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

Этот блок называется SKB и представляет собой экземпляр структуры sk_buff, которая содержит указатель на буфер с данными, а также указатели на предыдущий и следующий экземпляр, что позволяет формировать список блоков.

После этого блок передается «модулю» IP, который формирует заголовок IP, а также ищет для пакета маршрут.

IP-пакет отправляется в «подсистему соседей», где для отправки выбирается аппаратный адрес — MAC получателя. Он может либо находиться в кэше, либо быть получен через ARP — протокол, который позволяет узнать адреса узлов в одном коллизионном домене, — или через IPv6, который имеет для этого встроенные механизмы. ARP мы подробнее рассмотрим в главе 10.

На рисунке вышесказанное представлено в виде упрощенной схемы стека протоколов.

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

Далее отправка трафика планируется исходя из приоритета и необходимости уменьшения задержек.

Некоторые устройства поддерживают аппаратное управление трафиком, в этом случае управление трафиком может выполняться прямо на сетевом оборудовании.

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

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

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

Современное оборудование, как правило, напрямую пишет данные в основную память машины, используя DMA — Direct Memory Access.

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

Дальнейшие действия показаны на рисунке упрощенно. Обработчик прерывания ставит в очередь планировщика задачу и завершается. Выполняемая планировщиком задача копирует данные из кольцевого буфера в одну из очередей, каждую из которых обрабатывает одно из вычислительных ядер процессора. Отсюда данные передаются стеку, и он по цепочке передает их соответствующим модулям протокола, например IP, затем TCP или UDP, убирая заголовок на каждом из уровней. Здесь же выполняется маршрутизация, применяются фильтры nftables и проверяется соответствие адресов в модулях протоколов, ориентированных на соединение.

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

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

Сходства и различия сетевого API на разных платформах

Все современные операционные системы с поддержкой сокетов имеют примерно одинаковый сокетный API, более или менее соответствующий POSIX. Лишь в некоторых старых или специфических ОС интерфейс заметно отличается.

Разница между BSD- и POSIX-сокетами

Как уже было сказано, изначально сокеты были реализованы в Университете Беркли в системах BSD — Berkeley Software Distribution. Впервые они появились в 4.2BSD. До сих пор в разных версиях BSD-систем используются сокеты Беркли.

Позднее комитет 1003 IEEE с целью облегчения переноса кода программ, унификации интерфейсов и ускорения создания документов включил сокетный API в набор стандартов, описывающих интерфейсы между ОС и программой, или POSIX — Portable Operating System Interface. Как уже говорилось в главе 1, последняя на момент написания книги редакция — POSIX.1-201735.

Сокеты стали частью стандарта IEEE 1003.1g System Application Program Interface.

Отличия между стандартизованными POSIX-сокетами и сокетами Berkley есть, но не очень значительные:

• inet_ntop() в POSIX — функция преобразования IP-адреса в строку. В BSD называется inet_ntoa().

• inet_pton() в POSIX — функция преобразования строки в IP-адрес. В BSD называется inet_aton().

• Функция getaddrinfo() в POSIX заменяет устаревшие функции gethostbyname(), gethostbyaddr(), getservbyname(), которые до сих пор актуальны в BSD.

• Функция getnameinfo() в POSIX заменяет устаревшие функции gethostbyaddr(), getservbyport().

Отличия в разных ОС

В Microsoft Windows существует своя реализация, которая называется Windows Socket 2 API, или WSA.

Данный API предоставляет:

• API для приложений;

• SPI для расширения сокетной библиотеки. Например, он позволяет добавить работу с новым протоколом.

Особенность WSA — неполная совместимость API с POSIX-стандартом, а также отдельный набор полностью несовместимых функций. За исключением некоторых ситуаций, лучше использовать POSIX API, чтобы обеспечить переносимость между разными ОС.

Стандартные POSIX-функции часто выполнены поверх вызовов, специфичных для Windows. Мы еще поговорим о WinSock 2 в главе 14 и далее.

В некоторых Unix-подобных ОС также есть фреймворк STREAMS, который реализует обмен сообщениями между процессами, в том числе по сети, через вызовы getmsg() и putmsg(). Этот фреймворк использовали и в ранних версиях Windows NT.

В ОС типа Genode36 или Plan 937 существует файловый интерфейс на основе STREAMS. ОС представляет TCP/IP-стек как псевдофайловую систему. В ней можно создавать специальные файлы, записывать в них и читать из них. В результате будет осуществляться сетевое взаимодействие. Интересная возможность данного фреймворка в том, что он позволяет реализовать модуль поддержки транспортных протоколов в пространстве пользователя без доступа к исходным кодам ядра.

ОС QNX, даже последние версии QNX Neutrino, до сих пор поддерживают сеть QNet, отдаленно напоминающую NetBIOS из Windows. QNet реализована без использования TCP/IP и сокетного интерфейса. Работать с этой сетью можно через файловую систему.

Дополняет ее сеть GNS — Global Name Service, — реестр служб, которые позволяют обращаться к ним независимо от того, где они работают. Здесь можно провести некоторую параллель с WINS и DNS.

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

Все упомянутые ОС поддерживают TCP/IP-стек и сокетный API. Мы изучим API, но постепенно будем от него отходить в сторону использования библиотек высокого уровня, которые скрывают большинство деталей взаимодействия с конкретной платформой.

Сокетный API в разных языках

Мы уже видели примеры сокетного API Python, хотя и не углублялись в детали его реализации.

Сейчас попробуем разобраться, как реализованы вызовы сокетного API в других языках, отличающихся от C и C++, на примере языка Go.

В Go функция socket() реализована в модуле syscall по-разному для разных ОС. Чаще всего через вызов socket().

Например, реализация для Unix выглядит так:

func Socket(domain, typ, proto int) (fd int, err error) {

if domain == AF_INET6 && SocketDisableIPv6 {

return -1, EAFNOSUPPORT

}

fd, err = socket(domain, typ, proto)

return

}

А так — реализация для Windows:

func Socket(domain, typ, proto int) (fd Handle, err error) {

if domain == AF_INET6 && SocketDisableIPv6 {

return InvalidHandle, EAFNOSUPPORT

}

return socket(int32(domain), int32(typ), int32(proto))

}

Видно, что отличия незначительны.

В Linux функция socket() вызывается из стандартной библиотеки C.

В большинстве дистрибутивов Linux стандартной библиотекой является GLibC. Но в некоторых случаях могут использоваться другие реализации, например uCLibC для встраиваемых устройств.

В Windows эта функция вызывается как ws2_32.socket() из библиотеки ws2_32. Вызов реализован в windows/zsyscall_windows.go и выглядит так:

modws2_32 = NewLazySystemDLL("ws2_32.dll")

...

procsocket = modws2_32.NewProc("socket")

...

func socket(af int32, typ int32, protocol int32) (handle Handle, err error) {

r0, _, e1 := syscall.Syscall(procsocket.Addr(), 3, uintptr(af),

uintptr(typ), uintptr(protocol))

handle = Handle(r0)

if handle == InvalidHandle {

err = errnoErr(e1)

}

return

}

Отсюда видно, что системная библиотека языка просто загружает необходимые динамические библиотеки, используя механизмы ОС, которые реализованы в самом языке. Функция Syscall(), например, реализована в библиотеке Go на ассемблере.

Существует еще более низкоуровневая функция RawSyscall().

Разница между двумя функциями в том, что Syscall уведомляет среду выполнения, что она переключилась на блокирующий системный вызов, а RawSyscall() просто блокируется, не уведомляя среду.

Над загруженными функциями есть несколько оберток, каждая из которых выполняет проверки, постепенно приближая интерфейс C функции к интерфейсу, привычному для разработчика на Go. В частности, эти вызовы использует стандартная библиотека Net в Go. Код мы здесь приводить не будем, так как он достаточно сложный и включает на сокетах неблокирующий режим, который мы рассмотрим только в книге 2. Желающие могут найти его самостоятельно.

Посмотрим, как создать сокет на Go:

package main

import (

"fmt"

"syscall"

)

func main() {

if s, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM,

syscall.IPPROTO_TCP); err != nil {

fmt.Printf("Error message: %s\n", err)

} else {

fmt.Printf("Descriptor: %d\n", s)

}

}

Результат:

src/book01/ch02/go ➭ go run main.go

Descriptor: 3

Пакет Net стандартной библиотеки предоставляет удобные «методы», например Dial() типов net.Dialer или net.Conn, и функции, например Listen(), которые включают сразу несколько вызовов сокетного API.

В языке Rust подход аналогичный: стандартная библиотека std::net, в которой реализованы высокоуровневые примитивы, например TcpListener, TcpStream или UdpSocket. Но «под капотом» лежит сокетный API.

Может возникнуть вопрос: имеет ли смысл использовать низкоуровневый сокетный API в Go и схожих языках, если детали скрыты внутри сетевых библиотек?

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

Например, установка опций выполняется в функции-обработчике, но через прямой вызов сокетного API:

func main() {

dialer := &net.Dialer{

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

Control: func(network, address string, conn syscall.RawConn) error {

var operr error

if err := conn.Control(func(socket_descriptor uintptr) {

// Вызов сокетного API.

operr = syscall.SetsockoptInt(int(socket_descriptor),

syscall.SOL_SOCKET,

syscall.TCP_QUICKACK, 1)

}); err != nil {

return err

}

return operr

},

};

// Вызов Dialer.Dial().

dialer.Dial("tcp", "google.com:443");

}

В конечном итоге опция TCP_QUICKACK устанавливается через вызов C функции setsockopt(). Чтобы вести разработку эффективно, нужно понимать, что этот код делает и для чего.

Установку опций мы рассмотрим в главе 8.

Общий процесс работы с сокетами

Все сокеты принимают и отправляют данные. Они работают согласно протоколу — описанию правил взаимодействия. Поэтому тип сокета полностью определяется стеком протоколов, лежащим под ним. «Сетевой» уровень OSI задает формат адреса, даже если сеть не используется.

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

Тип передачи

Тип данных

Пример сокета и протокола для семейства AF_INET

Соединение

Поток

SOCK_STREAM, TCP

Соединение

Сообщения

SOCK_SEQPACKET, SCTP

Передача без соединения

Сообщения

SOCK_DGRAM, UDP

Уровни выше транспортного в основном не представлены сокетным API: эти протоколы реализуются поверх него в приложениях.

Такие протоколы, как UDP, ориентированы на обмен сообщениями. UDP не обеспечивает надежной или упорядоченной доставки сообщений.

PDU UDP называется дейтаграмма.

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

В стеке TCP/IP существует протокол RDP или RUDP — Reliable UDP, который также обеспечивает надежную передачу дейтаграмм.

Протокол SCTP тоже ориентирован на сообщения, но он предлагает сервис надежной и упорядоченной доставки сообщений, то есть обеспечивает соединение.

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

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

Клиент

Алгоритм работы клиента очень прост и представлен на рис. 3.7:

1. Создать сокет вызовом функции socket().

2. Опционально вызвать функцию bind().

3. Опционально вызвать функцию connect().

4. В цикле:

4.1. Отправить данные серверу, используя send() или sendto().

4.2. Прочитать ответ сервера, используя recv() или recvfrom().

5. Закрыть сокет с помощью close() или closesocket().

Рис. 3.7. Алгоритм работы клиента

Независимо от того, ориентирован клиент на соединение или нет, можно использовать функцию connect():

• Если сокет ориентирован на сообщения, эта функция не обязательна, но она сохраняет адрес сервера в структуре адреса сокета. Это дает возможность использовать функции send()/recv() или read()/write(), которые не требуют указания адреса.

• В ином случае, например для протокола TCP, функция connect() обязательна, так как инициирует последовательность установления соединения протоколом.

Внимание! Когда используется функция connect(), в случае не ориентированного на соединение сокета сохраняется адрес сервера. Но эта информация не определяется протоколом. Например, в UDP такой поддержки нет. Поэтому в данном случае неверно говорить об установке соединения.

Ранее мы использовали функцию bind(), которая также привязывает адрес к сокету. Ее отличие от connect() в том, что последняя кроме инициирования соединения устанавливает адрес удаленной стороны, а bind() — локальный адрес сокета.

В клиенте вызов bind() не является обязательным, он нужен лишь в том случае, когда необходимо использовать для подключения определенный локальный интерфейс, который будет выбран по адресу, или для того, чтобы задать клиентский порт вручную, а не разрешать ОС выбирать его автоматически.

Зачем задавать клиентский порт вручную? Например, если межсетевой экран разрешает ответ на соединения только с заданным диапазоном портов. Такие конфигурации редки, но могут существовать.

Сервер

Алгоритм сервера, показанный на рис. 3.8, несколько сложнее, чем клиента:

1. Создать сокет вызовом функции socket().

2. Привязать сокет к прослушиваемому порту, а также к интерфейсу вызовом функции bind().

3. Для сокетов, ориентированных на соединение:

3.1. Создать очередь для ожидания подключений, вызвав listen().

3.2. Принять новое подключение через вызов accept(). Функция вернет сокет для работы с новым клиентом. Сокет, который был создан до этого, будет и дальше ожидать подключения.

4. В цикле ожидать данные от клиента, например, принимая их через recv() или recvfrom(). После получения данных функция также вернет адрес отправившего их клиента.

5. Отправить клиенту ответ через send() или sendto().

6. Закрыть сокет, используя close() или closesocket().

Рис. 3.8. Алгоритм работы сервера

Детали API сервера мы рассмотрим в главе 5 и некоторых других главах.

В случае протоколов, не ориентированных на соединение, например UDP, вызовы listen() или accept() приведут к ошибке типа «Операция не поддерживается».

Такие «серверы» должны ожидать поступления данных от любого источника.

В случае протоколов, ориентированных на соединение, функция listen() создает очередь запросов на подключение к сокету и запускает последовательность ожидания и приема.

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

Означает ли это, что UDP-сокеты не предназначены для многопоточной обработки? В общем случае — да. Таков сокетный API.

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

Когда сервер, ориентированный на соединение, завершает работу с клиентом, он должен закрыть сокет, который вернул accept(), чтобы не допустить утечки ресурсов, в особенности номеров дескрипторов.

Сокеты домена Unix

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

В качестве адресного пространства данные сокеты используют файловую систему, а семейство адресов задается константой AF_UNIX или эквивалентным POSIX-вариантом AF_LOCAL.

Unix-сокеты могут быть использованы для управления различными демонами. При этом часто они создаются в каталоге /run.

Например, сокет для управления Docker — /run/docker.sock. Некоторые контейнеры, содержащие управляющие интерфейсы, например Portainer, монтируют этот файл, чтобы взаимодействовать с демоном.

Запустить контейнер можно так:

➭ sudo curl -XPOST --unix-socket /run/docker.sock

http://localhost/containers/12345/start

{"message":"No such containr: 12345"}

Файл сокета для управления Udev имеет путь /run/udev/control. Его используют systemd и udevadm для передачи команд демону.

Сокет для управления MariaDB по умолчанию находится по адресу /run/mysqld/mysqld.sock. В данном случае Unix-сокет — один из вариантов каналов для взаимодействия СУБД и управляющих интерфейсов.

Он используется, чтобы ограничить доступ к СУБД извне узла с целью безопасности.  Unix-сокеты достаточно широко применяются в приложениях.

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

Данный тип сокетов работает только в рамках локальной машины.

Это утверждение необходимо понимать буквально.

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

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

ssh \

-R/var/run/mysql.sock:/var/run/mysql.sock \

-R127.0.0.1:3306:/var/run/mysql.sock \

user@server

Или утилиту socat:

socat "UNIX-LISTEN:./mysqld.sock,reuseaddr,fork" \

EXEC:'ssh user@server socat STDIO UNIX-CONNECT\:/var/run/mysqld/mysqld.sock'

В этом случае Unix-сокет будет создан локально работающим процессом и обмен будет производиться с ним, а этот процесс будет перенаправлять данные удаленному процессу, используя TCP-сокет.

В свою очередь, удаленный процесс будет записывать принятые данные в существующий Unix-domain сокет на машине, где он выполняется, и отправлять по TCP прочитанные из него данные.

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

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

Таким образом, сокеты домена Unix обеспечивают высокую производительность.

Название Unix-domain стоит понимать как «локальные сокеты», а не «сокеты, которые есть только в Unix».

В версиях ОС Windows Insider Build 17063, то есть с 2017 года, имеется ограниченная поддержка таких сокетов. Поддерживаются типы SOCK_DGRAM и SOCK_SEQPACKET.

С данным типом сокетов часто используют функцию socketpair(), которая может создавать любые сокеты из разных доменов, но полезнее она для сокетов Unix-domain:

#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int socket_vector[2]);

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

• domain, type, protocol — такие же, как для функции socket().

• socket_vector — массив для сохранения возвращенных дескрипторов.

Функция создаст пару связанных дескрипторов анонимных сокетов и вернет 0 в случае успеха и –1 в случае неудачи.

Сокеты домена Unix могут быть следующих типов:

• SOCK_STREAM — через этот сокет проходит поток данных. Его преимущества — надежная доставка и сохранение порядка данных. Но в нем отсутствуют границы между «сообщениями», то есть вызовами отправки данных. Это поток.

• SOCK_DGRAM — обмен дейтаграммами без соединения, сохраняющими границы сообщений. Надежность обмена не гарантируется, как и сохранение порядка сообщений. Но на практике сокеты домена Unix всегда сохраняют порядок сообщений, а потеряться сообщения не могут.

• SOCK_SEQPACKET — надежный обмен сообщениями, гарантированная доставка, сохранение порядка, сохранение границ. Для сокетов домена Unix на практике не отличается от SOCK_DGRAM.

Процесс использования функции для межпроцессного обмена следующий:

1. Вызвать socketpair() — создать два файловых дескриптора сокета, которые будут являться двумя концами одного канала. Один произвольный конец родительский, второй — дочерний.

2. Создать еще один процесс. Вызвать fork(), CreateProcess() или подобную функцию. Можно использовать сокеты и для обмена между потоками, но в этом случае более оптимальна общая память.

3. В потомке:

3.1. Закрыть дескриптор родительского файла.

3.2. Сохранить дочерний дескриптор, чтобы использовать для обмена с предком.

4. В текущем процессе, который является предком:

4.1. Закрыть дескриптор дочернего сокета.

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

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

Рассмотрим, как использовать данную функцию на C++ в Linux.

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

extern "C"

{

#include <sys/socket.h>

// Для read() и write().

#include <unistd.h>

}

...

constexpr size_t buf_size = 1024;

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

void child(int socket)

{

//

// Эта функция выполняется потомком.

//

std::string buf(buf_size, 0);

// Чтение из сокета в Unix-подобных системах может выполняться

// через read().

// Сокет здесь представлен обычным файловым дескриптором.

const ssize_t res = read(socket, &buf[0], buf.size());

if (res < 0)

{

// Обработать ошибки чтения.

perror("child read");

return;

}

std::cout

<< "Child received: '"

<< buf << "' (" << res << " bytes)"

<< std::endl;

const std::string msg("Hello parent, I am child");

// Запись также может быть выполнена через write().

if (write(socket, msg.c_str(), msg.size()) < 0)

{

// Обработать ошибки записи.

}

close(socket);

}

Код предка вынесем также в отдельную функцию. Обратите внимание, что предок сначала пишет, затем читает, тогда как потомок — наоборот:

void parent(int socket)

{

//

// Эта функция выполняется предком.

//

std::string buf = "Hello, I'm your parent";

if (write(socket, buf.c_str(), buf.size()) < 0)

{

// Обработать ошибки записи/отправки данных в сокет.

perror("parent write");

return;

}

// Снова прочитать данные.

ssize_t res = read(socket, &buf[0], buf.size());

if (res < 0)

{

// Обработать ошибки чтения.

perror("parent read");

return;

}

buf.resize(res);

std::cout

<< "Parent received: '"

<< buf << "' (" << res << " bytes)"

<< std::endl;

close(socket);

}

Внимание! Переменная, содержащая результат вызова read() и write(), должна иметь знаковый тип ssize_t, который используется, чтобы можно было сохранить отрицательный результат, возвращаемый функциями при ошибке.

В функции main() создадим массив дескрипторов, вызовем socketpair() и fork(). Процессам для взаимодействия будет передан массив дескрипторов.

int main()

{

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

int fd[2];

constexpr int parent_socket = 0;

constexpr int child_socket = 1;

// Вызвать socketpair(): PF_LOCAL — то же, что и PF_UNIX.

if (-1 == socketpair(PF_LOCAL, SOCK_DGRAM, 0, fd))

{

perror("socketpair");

return EXIT_FAILURE;

}

// Создать новый процесс.

const pid_t pid = fork();

if (0 == pid)

{

// Потомок. Родительский дескриптор больше не потребуется.

// Нужно его закрыть.

close(fd[parent_socket]);

child(fd[child_socket]);

}

else if (-1 != pid)

{

// Родитель. Дескриптор потомка больше не потребуется.

// Нужно его закрыть.

close(fd[child_socket]);

parent(fd[parent_socket]);

}

else

{

// Ошибка создания процесса.

perror("fork");

}

return EXIT_SUCCESS;

}

Скомпилируем и запустим пример:

➭ build/bin/b01-ch03-socket-pair-test

Child received: 'Hello, I'm your parent'

Parent received: 'Hello parent, I am child'

Функция socket.socketpair() существует и в Python. Она имеет ту же сигнатуру, что и C-функция socket(), но возвращает кортеж из двух объектов класса socket.socket.

Интересно, что в библиотеке socket она может быть реализована как вызов из C-библиотеки в ОС, где данная функция существует. Но если в какой-то системе такого вызова нет, включается реализация на чистом Python через стек TCP/IP.

В итоге на Python эта функция работает везде. Чуть позже мы рассмотрим ее реализацию на Python.

Теперь попробуем «удлинить провод», которым связаны процессы. Использовать socketpair() и анонимные сокеты здесь уже не получится. Чтобы связать разные процессы, к сокету надо привязать адрес. Для сокетов домена Unix адрес — это имя файла, и он будет описываться такой структурой:

#include <sys/un.h>

struct sockaddr_un

{

// AF_UNIX.

unsigned short sun_family;

// Адрес сокета.

char sun_path[108];

};

В зависимости от содержимого атрибута sun_path имеются следующие типы адресов:

• Привязанные к файловой системе, или сокеты пути. Атрибут содержит путь в файловой системе, заканчивающийся нулем. Размер адреса, передаваемый в bind(), равен sizeof(sockaddr_un).

• Абстрактные. Первый символ, то есть sun_path[0], равен нулю — '\0'. Имя не связано с путем в файловой системе. Адрес сокета в этом пространстве имен задан байтами в sun_path, начиная с sun_path[1] и до переданного в bind() размера структуры минус sizeof(sun_family).

• Безымянные. Потоковый сокет, который не имеет имени. Либо оно не было привязано через bind(), либо это пара сокетов, возвращенная функцией socketpair().

Абстрактные сокеты — это нововведение Linux. Они нужны, когда Unix-сокет используется только как примитив для межпроцессного обмена и не предполагается взаимодействовать с приложением через утилиты файловой системы.

Имена таким сокетам даются, как правило, как если бы сокет находился в файловой системе, например: «\0/com/ubuntu/upstart». Проблемой в этом случае будет возможная уязвимость приложения. Данный тип сокетов не ограничен правами, поэтому к ним может подключиться кто угодно. И кто угодно может отправить в такой сокет произвольные данные.

Стоит использовать getsockopt() с SO_PEERCRED, чтобы получить как минимум UID подключающегося клиента и отсечь пользователей, которые не запускали сервер.

Внимание! Путь сокетов, привязанных к ФС, всегда должен заканчиваться нулем. Хотя некоторые реализации Unix-сокетов добавляют завершающий ноль, другие могут вернуть ошибку. Кроме того, в некоторых реализациях sun_path имеет длину всего 92 байта.

Посмотрим, как будет выглядеть бывший процесс «родитель»:

// Имя файла для сокета.

constexpr char SOCK_PATH[]{"_socket_path"};

int main()

{

// Адрес сокета.

sockaddr_un sock_address;

// PF_UNIX — он же PF_LOCAL.

int sock = socket(PF_UNIX, SOCK_DGRAM, 0);

if (-1 == sock)

{

perror("socket");

return EXIT_FAILURE;

}

// Необходимо заполнить структуру "адреса".

sock_address.sun_family = AF_UNIX;

assert(sizeof(SOCK_PATH) < sizeof(sock_address.sun_path));

std::copy(SOCK_PATH.begin(), SOCK_PATH.end(), sock_address.sun_path);

if (std::filesystem::exists(SOCK_PATH) && std::remove(SOCK_PATH) != 0)

{

perror("remove");

return EXIT_FAILURE;

}

// После вызова bind() будет создан файл сокета.

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

sizeof(sock_address)))

{

// Выполнение действий при ошибке связывания адреса.

perror("bind");

return EXIT_FAILURE;

}

Чтение данных также реализуем в цикле:

std::string buf(1024, 0);

while (true)

{

// Read ждет новых данных и записывает их в буфер.

ssize_t bytes_read = read(sock, &buf[0], buf.size());

if (bytes_read < 0)

{

perror("server read");

return EXIT_FAILURE;

}

std::cout

<< "Buffer: '"

<< buf << "'"

<< std::endl;

}

return EXIT_SUCCESS;

}

После вызова данной программы будет создан файл сокета:

➭ ls -l _socket_path

srwxr-xr-x 1 artiom artiom 0 мая  5 20:35 _socket_path

Видим, что его тип — 's', то есть socket. Записывать что-либо в этот файл, например, через echo, не имеет смысла: вывод завершится с ошибкой «Нет такого устройства или адреса». Что логично: это сокет, а не канал.

Взаимодействовать с приложением можно, используя BSD Netcat:

➭ ncat -uU _socket_path

test

^C

Ключ -U говорит о том, что работа производится с Unix-domain-сокетом, а ключ -u — о том, что сокет ориентирован на сообщения.

Процесс, который читает сокет, выведет:

➭ build/bin/b01-ch03-unix-socket-server

Buffer: 'test

'

Перевод строки также будет передан из Netcat и отображен в принятом сообщении.

Теперь реализуем программу, которая пишет в сокет так же, как это делает Netcat или дочерний процесс выше:

constexpr char SOCK_PATH[]{"_socket_path"};

int main()

{

struct sockaddr_un sock_address;

int sock = socket(AF_UNIX, SOCK_DGRAM, 0);

if (-1 == sock)

{

perror("socket");

return EXIT_FAILURE;

}

// Заполнение структуры адреса.

sock_address.sun_family = AF_UNIX;

std::copy(SOCK_PATH.begin(), SOCK_PATH.end(), sock_address.sun_path);

const std::string msg{ "Hello, process!" };

// В sendto() адрес передается явно.

if (-1 == sendto(sock, msg.c_str(), msg.size(), 0,

reinterpret_cast<const sockaddr*>(&sock_address),

sizeof(sock_address)))

{

perror("send");

return EXIT_FAILURE;

}

close(sock);

return EXIT_SUCCESS;

}

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

➭ {build/bin/b01-ch03-unix-socket-server & } && \

build/bin/b01-ch03-unix-socket-client

[3] 1336230

Buffer: 'Hello, process!'

Сначала запускается процесс сервера, который выполняется в фоне. Затем начинает выполняться клиент. Числа в начале — это номер фоновой задачи и PID сервера. Их выводит оболочка.

Далее сервер печатает то, что ему отправил клиент по сокету.

Попробуем отправить данные из кода на Python:

import socket

s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM, 0)

s.sendto('This is Python!'.encode(), '_socket_path')

Результат предсказуем:

...

Buffer: 'Hello, process!'

Buffer: 'This is Python!'

Внимание! Сервер продолжит работать в фоне. Чтобы завершить его, выполните команду kill <PID>, где <PID> — выведенное оболочкой число. В данном примере: kill 1336230.

Видно, что мы использовали функцию sendto(). Если процесс-писатель будет запущен первым и сокет еще не создан, он завершится с ошибкой. Вызов bind() на стороне клиента мог бы исправить данную проблему: он создаст файл сокета.

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

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

Есть сервер, предоставляющий услугу, и клиент, услугу запрашивающий. При использовании функции socketpair() процесс, обслуживающий сокет, запущен априори. Но когда образуется более «длинный» канал, процессы разделяют функции между собой.

За создание точки подключения, в данном случае файла сокета, также отвечает сервер, а не клиент. И сервер, при выходе, должен удалить файл сокета, что в примерах не реализовано.

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

Дейтаграммные сокеты INET-доменов

Рассмотрим сокеты, используемые для обмена данными по сети, доменов AF_INET и AF_INET6 типа SOCK_DGRAM.

Чаще всего для работы с данными сокетами используется UDP, описанный в RFC 768 «User Datagram Protocol».

Вторым протоколом является RDP — Reliable Datagram Protocol — или RUDP — Reliable User Datagram Protocol, определенный в RFC 908 «Reliable Data Protocol» и RFC 1151 «Version 2 of the Reliable Data Protocol (RDP)».

RDP похож на TCP и обеспечивает надежную упорядоченную передачу, но только не потока данных, а сообщений. Однако данный протокол не обновлялся с 1990 года, и не в каждой реализации сетевого стека его получится найти. Поэтому можно считать, что в случае данных сокетов протокол для передачи дейтаграмм один — UDP.

Внимание! Существует еще один протокол RDP — Remote Desktop Protocol. Он предназначен для удаленного администрирования узлов через графический интерфейс. Это проприетарный протокол прикладного уровня и к Reliable Datagram Protocol не имеет никакого отношения.

UDP

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

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

Если сервер получает данные от клиента, он не подтверждает их получение.

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

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

Рис. 3.9. Структура UDP дейтаграммы

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

Контрольная сумма

Поле характерно для многих протоколов. Когда дейтаграмма UDP отправляется поверх IPv4, поле контрольной суммы может быть заполнено нулями, что означает, что контрольная сумма не будет рассчитана. Для ее расчета поле контрольной суммы сначала принимается равным 0, а затем производится расчет всех остальных данных по алгоритму, описанному в RFC 1071 «Computing the Internet Checksum».

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

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

Внимание! В IPv6 не предусмотрен контроль целостности на сетевом уровне. Поэтому расчет контрольной суммы при его использовании обязателен. В этом случае контрольная сумма будет покрывать и сетевые адреса, как того требует RFC 2460 «Internet Protocol, Version 6», раздел 8.1.

Длина

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

Минимальный размер — 8 байт, то есть только заголовок, без данных.

Обычно UDP работает поверх IP. Поэтому на каждую дейтаграмму приходится не только UDP-, но и IP-заголовок, то есть при таком объеме полезной нагрузки процент полезных данных будет меньше, чем служебных.

Теоретический максимальный предел — 65 507 байт — определяется по следующей формуле:

0xffff — (sizeof(IP-заголовок) + sizeof(UDP-заголовок)) = 65535 — (20 + 8) = 65507

Здесь 0xffff — максимальное значение, которое может хранить поле размера.

Но большинство протоколов, работающих поверх UDP, ограничиваются гораздо меньшим размером — обычно либо 512, либо 8192 байта. Часто можно безопасно увеличить размер до 548. Такое ограничение размера обусловлено тем, что сетевые устройства могут отправлять кадры определенного максимального размера — MTU, или Maximum Transmission Unit. Например, Ethernet-адаптер имеет MTU, равный 1500.

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

Если дейтаграмма с IP-заголовком, который вместе с опциональными полями может иметь размер до 60 байт, а также заголовками протоколов, в которые могут быть инкапсулированы дейтаграммы, например, при использовании IPSec, превысит MTU, протокол IP ее фрагментирует. Это значительно повысит вероятность того, что дейтаграмма не будет доставлена, то есть увеличит потери. Поэтому лучше оставить разумный запас байтов для заголовков, чтобы дейтаграмма была отправлена без фрагментации.

Обычно считается, что достаточно 512 байт полезной нагрузки, но фактический максимальный безопасный размер полезной нагрузки — 508 байт.

Согласно RFC 791 «Internet Protocol» все узлы должны принимать дейтаграммы размером до 576 октетов, независимо от того, приходят ли они целыми или фрагментированными.

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

Число 576 выбрано, чтобы в дополнение к заголовку мог быть передан блок данных разумного размера.

Максимальный размер заголовка IP — 60 октетов. Размер заголовка UDP — 8 октетов.

576 — 60 — 8 = 508 октетов или байт.

Порты

Дейтаграммы UDP должны приходить конкретному процессу, и этот процесс необходимо идентифицировать. Поэтому в UDP и других протоколах транспортного уровня определено такое понятие, как порт. Это число, которое связывает блок данных протокола или PDU и процесс, которому этот блок должен быть отправлен.

Таким образом:

• IP-адрес указывает машину, которой нужно отправить данные. А точнее — сетевой интерфейс, которых у машины может быть несколько.

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

Пространство портов для каждого протокола разное. Одновременно могут работать процессы, которые прослушивают, например, порты 80 TCP и 80 UDP.

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

Не все протоколы имеют порты. Например, у ICMP порта нет, так как его PDU не адресованы конкретным процессам, а отправляются сетевому стеку узла. Такие PDU будут видны любому процессу в системе, если он имеет на это полномочия.

Но для транспортных протоколов использование портов характерно.

Эфемерные порты

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

Например, TCP-сокет, создаваемый сервером при подключении клиента, или UDP-сокет для приема данных на стороне клиента имеют свои номера портов.

Диапазон, из которого они выделяются, стандартизован IANA: 49152-65535. Но некоторые устаревшие системы, такие как Windows до версии Vista, используют нестандартные диапазоны, например 1024-5000. В Linux диапазон настраивается и по умолчанию равен 32768-60999.

Эти порты ОС назначает автоматически. Программист может задать порт явно, но чаще всего это не имеет смысла.

Стандартные порты

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

Известные стандартные номера регистрируют в IANA. Сейчас их уже несколько тысяч. Сначала номера соответствовали значениям меньше 1000, поэтому современные ОС запрещают приложениям без привилегий администратора использовать «общеизвестные» порты с номерами до 1024. Также эти порты называются привилегированными. Они гарантированно не будут использоваться как эфемерные. Зачастую программы, которые их занимают, являются системными демонами.

Среди номеров портов интересны следующие:

• 0/TCP,UDP — на стороне отправителя в bind() используется для указания свободного порта, обычно случайного. Не может появиться в PDU, и если он там есть, это ошибка. Номера портов начинаются с 1.

• 1/TCP,UDP — мультиплексор, описанный в RFC 1078 «TCP Port Service Multiplexer (TCPMUX)»38. Позволяет работать с несколькими службами через один порт.

• 7/TCP,UDP — ECHO-сервер. Предназначен для тестирования.

• 9/TCP,UDP, SCTP — DISCARD. Принимает данные, не обрабатывая их и отбрасывая после приема. Может быть использован для Wake on LAN, то есть включения компьютера по сети.

• 20, 21/TCP — FTP, File Transfer Protocol. Незащищенный протокол для передачи файлов.

• 22/TCP — Secure Shell, или SSH. Позволяет управлять машиной с помощью Shell через защищенное соединение.

• 80/TCP — HTTP. Незащищенный протокол, используемый для работы WWW-браузера.

• 443/TCP — HTTPS. Защищенный HTTP. Сейчас этот протокол используют большинство сайтов WWW.

• 666/TCP,UDP — игра Doom.

• 708, 732-740, 743/TCP,UDP — свободные порты. Кроме них существуют и другие диапазоны свободных портов.

• 989, 990/TCP,UDP — FTPS. Защищенный протокол для передачи файлов.

• 993/TCP, UDP — IMAPS, Internet Message Access Protocol Secure. Защищенный протокол для обмена почтой между почтовым клиентом и сервером.

• 1011-1020/TCP,UDP — зарезервированы. Не используются.

• 1021, 1022/TCP, UDP — зарезервированы для сетевых экспериментов.

• 1023/TCP,UDP — зарезервирован. Не используется.

• 1024/TCP,UDP — зарезервирован. Не должен использоваться. Но его используют, например, K Display Manager или NetMeeting, а также несколько червей и троянов.

Функции для работы с портами

В Linux соответствие названий служб и используемых ими протоколов хранится в файле /etc/services. А соответствие номеров портов и названий протоколов хранится в файле /etc/protocols.

Для получения номера порта по названию службы или порту используются следующие функции:

#include <netdb.h>

struct servent

{

// Официальное имя сервиса.

char *s_name;

// Список псевдонимов.

char **s_aliases;

// Номер порта.

int  s_port;

// Используемый протокол.

char *s_proto;

};

// Возвращает структуру servent, для сервиса с именем name,

// использующего протокол proto.

// Если proto нулевой, подойдет любой протокол.

servent *getservbyname(const char *name, const char *proto);

// Возвращает структуру servent для строки, совпадающей с портом port,

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

// proto.

// Если proto нулевой, подойдет любой протокол.

servent *getservbyport(int port, const char *proto);

Для получения стандартных номеров портов по названию протокола и обратно существуют функции getprotobyname() и getprotobynumber():

#include <netdb.h>

struct protoent

{

// Официальное название протокола.

char  *p_name;

// Список псевдонимов.

char **p_aliases;

// Номер протокола.

int p_proto;

};

// Возвращает структуру protoent, соответствующую имени протокола.

// Например "tcp", "udp"...

protoent *getprotobyname(const char *name);

// Возвращает структуру protoent, соответствующую коду протокола.

protoent *getprotobynumber(int proto);

Для навигации по базе протоколов также существуют функции:

// Открыть базу, если требуется.

// При каждом следующем вызове читает новую запись protoent из базы.

protoent *getprotoent();

// Открыть базу и установить указатель на первую запись.

void setprotoent(int stayopen);

// Закрыть базу.

void endprotoent();

Если параметр stayopen функции setprotoent() ненулевой, база не будет закрываться с каждым вызовом функции.

В общем случае для подобного API в GLibC есть несколько возможных бэкендов, выполняющих поиск: простой файл, DNS, NIS или даже X.500.

В большинстве систем программы обращаются к кэшу DNS, например к DNSMasq, а затем кэш выполняет DNS-запросы на быстрый сервер.

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

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

В случае неудачного завершения функции возвращают нулевой указатель, в противном случае — указатель на структуру.

В Python функции с аналогичными сигнатурами представлены в модуле socket, кроме функции getprotobynumber(), которая отсутствует:

def getservbyname(servicename, protocolname=None) -> int

def getprotobyname(name) -> int

def getservbyport(port, protocolname=None) -> string

В Linux C API также существует функция bindresvport():

#include <sys/types.h>

#include <netinet/in.h>

int bindresvport(int sockfd, struct sockaddr_in *sin);

Она используется для привязки сокета к привилегированному анонимному порту, номер которого произвольно выбирается из диапазона от 512 до 1023. В MacOS X — от 1 до 1023. Соответственно, успешно вызванная функция может быть только приложением, обладающим необходимыми правами или привилегиями.

Перед вызовом поле sockaddr_in должно быть установлено в AF_INET либо AF_INET6. Если поле не установлено, считается, что его значение — AF_INET.

Если привязка, выполнена успешно, то есть sin->sin_port установлен, функция вернет 0, в противном случае –1.

В Python данная функция отсутствует.

Применение этой функции зачастую нецелесообразно. Она вводилась для того, чтобы Network File System на сервере могла доверять идентификатору пользователя, который передает клиент. Если порт клиента меньше 1024, значит, на машине пользователя демон работает с правами root и пользователь не может подменить данные, отправляемые клиентом, так как предполагается, что у клиента root доступа нет.

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

Резюме

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

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

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

В сокетах обычно есть клиентская и серверная части. Клиент — компонент, который активно выполняет запрос на подключение, сервер — компонент, который ожидает подключения.

Алгоритм работы клиента:

1. Создать сокет с использованием функции socket().

2. При необходимости использовать функции bind() и connect().

3. Отправить данные серверу с помощью функции send() или sendto().

4. Прочитать ответ от сервера с помощью функции recv() или recvfrom().

5. Закрыть сокет с помощью функции close() или closesocket().

Алгоритм работы сервера:

1. Создать сокет с использованием функции socket().

2. Привязать сокет к прослушиваемому порту и интерфейсу с помощью функции bind().

3. Принять новое подключение с помощью функции accept().

4. В цикле на сокете, возвращенном функцией accept(), ожидать данных от клиента, например, с помощью функций recv() или recvfrom().

5. Отправить ответ клиенту с помощью функций send() или sendto().

6. Закрыть сокет с помощью функции close() или closesocket().

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

Для работы с дейтаграммными сокетами поверх IP в основном используется UDP. Он не гарантирует надежную передачу данных, но позволяет отправлять данные в несколько пунктов назначения и получать их из нескольких источников. Клиент может отправлять данные на сервер без ожидания и проверки готовности сервера к приему.

Сервер не подтверждает прием данных от клиента. Данные передаются в виде дейтаграмм, которые доставляются конкретному процессу, идентифицируемому с помощью порта.

Порты могут быть эфемерными — автоматически выбранными системой; обычными — выбранными явно, для сервера чаще всего из номеров выше 1024; или стандартными, то есть зарегистрированными IANA, из диапазона 1–1024.

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

1. Назовите основные компоненты сетевой подсистемы большинства ОС.

2. Кратко опишите процесс отправки данных в типовом сетевом стеке.

3. Каковы три характерные особенности сетевой подсистемы ОС Windows?

4. Что является центральным звеном сетевой подсистемы QNX?

5. В чем отличие реализации сети в QNX от реализации в большинстве других ОС?

6. Почему в стеке Linux очереди и различные дисциплины очередей существуют только для передачи?

7. Чем различаются POSIX- и BSD-сокеты?

8. В чем отличие WinSock 2 API от сокетного API POSIX-совместимых ОС?

9. Как реализован вызов функций сокетного API в Go?

10. Для чего на клиенте могут использоваться функции bind() и connect()?

11. Почему для сокетов без установки соединения вызовы функций bind() и connect() опциональны?

12. Обязателен ли на сервере вызов функции bind()? Почему?

13. Для чего обычно используются UNIX-сокеты и каков их принцип работы?

14. Перечислите возможные типы UNIX-сокетов.

15. Что собой представляет адрес UNIX сокета?

16. Перечислите возможные типы адресов UNIX-сокетов.

17. Какая функция используется обычно для создания только UNIX-сокетов и редко с другими семействами и почему?

18. Какой тип должна иметь переменная, содержащая результат выполнения функций чтения и записи? Почему?

19. В одном из примеров сервер остался работать в фоновом режиме. Как его завершить?

20. Зачем нужны протоколы, не гарантирующие надежную передачу данных?

21. Чем отличается работа клиента и сервера, когда используется протокол без установки соединения?

22. Что такое протокол RDP?

23. Для чего нужен UDP?

24. Зачем нужно рассчитывать контрольную сумму UDP дейтаграммы? Всегда ли это требуется при использовании UDP поверх IPv4? А поверх IPv6?

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

26. В заголовке UDP указан порт получателя и отправителя, но не указан IP-адрес. Каким образом дейтаграмма находит адресата?

27. Что такое порт и для чего он используется?

28. Что такое эфемерные порты?

29. Чем обычные порты отличаются от стандартных?

30. Напишите приложение, которое принимает в качестве параметра название протокола и выдает список стандартных портов, за которыми закреплен этот протокол.

35 https://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_10

36 https://genode.org/

37 https://ru.wikipedia.org/wiki/Plan_9

38 https://datatracker.ietf.org/doc/html/rfc1078

Глава 4. Простой обмен данными. Raw-сокеты

В интернете никто не знает, что ты собака.

Питер Штайнер, The New Yorker, 1993

Введение

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

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

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

Затем мы изучим raw-сокеты, которые позволяют отправлять и получать заголовки протоколов, сформированные на прикладном уровне. Используя их, мы реализуем взаимодействие по протоколу ICMP, который является частью стека TCP/IP и служит для передачи служебных и диагностических сообщений между узлами в сети, что играет критически важную роль в обслуживании интернет-коммуникаций, позволяя отслеживать состояние инфраструктуры.

Мы реализуем наиболее распространенный вариант использования ICMP — утилиту Ping для проверки доступности узлов в сети. И посмотрим, как на примере данной утилиты raw-сокеты используются в разных языках, в частности Python и Go.

Связь без установки соединения

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

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

Функции sendto() и recvfrom()

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

Прототипы функций:

#include <sys/socket.h>

ssize_t sendto(int socket, const void *message, size_t length,

int flags, const struct sockaddr *address,

socklen_t address_len);

ssize_t recvfrom(int socket, void *restrict message, size_t length,

int flags, struct sockaddr *restrict address,

socklen_t *restrict address_len);

Параметры этих функций следующие:

• socket — дескриптор сокета;

• message — буфер с отправляемым сообщением либо буфер под данные, которые будут приняты;

• length — длина буфера;

• flags — флаги, которые рассмотрены далее;

• address — адрес назначения либо адрес источника данных;

• address_len — длина адреса.

Результат –1 для функций может означать, что произошла ошибка. В противном случае результат — число отправленных или принятых байтов.

Дейтаграммные сокеты UNIX- и Internet-доменов также разрешают отправлять и принимать дейтаграммы нулевого размера. Принявшая их функция recvfrom() вернет 0, и это не будет ошибкой или завершением работы с клиентом.

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

Но принимающая данные функция recvfrom() может вернуть 0, что говорит о том, что данные отсутствуют. Или, наоборот, вернуть точный размер переданного буфера, что может означать как то, что принято именно столько данных, так и то, что буфера не хватило, а дейтаграмма записана в буфер частично. И это не является ошибкой. А при использовании флага MSG_WAITALL данные принимаются до заполнения буфера.

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

• MSG_DONTROUTE — в sendto() не маршрутизировать пакет. Для отправки пакета не будет использован шлюз, то есть данные будут отправлены только на узлы, которые доступны в локальной сети. Используется утилитами диагностики и маршрутизации. Данный флаг определен только для маршрутизируемых семейств протоколов. В Windows некоторые поставщики службы Windows Sockets могут его игнорировать.

• MSG_OOB — в sendto() указывает отправить внеполосные данные в сокеты, которые это поддерживают, например типа SOCK_STREAM. Нижележащий протокол также должен поддерживать отправку внеполосных данных. Если опция задана в recvfrom() получающего сокета, внеполосные данные будут приняты.

• MSG_PEEK — флаг указывает recvfrom() скопировать в буфер данные без удаления из очереди или внутреннего буфера. Последующий вызов recvfrom() вернет те же самые данные.

• MSG_WAITALL — флаг указывает recvfrom() ждать, пока не придет запрошенное количество данных. Вызов может вернуть меньше данных, чем было запрошено, в случае, если он прерван сигналом, если возникла ошибка, например разрыв соединения, а также если буфер сокета был переполнен или соединение было закрыто.

Прочие флаги для функций обмена данными

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

Кроме того, не все флаги работают с функциями sendto()/recvfrom(): некоторые из них устанавливаются только для протоколов, ориентированных на соединение, таких как TCP.

Рассмотрим несколько примеров флагов. Все они отсутствуют в ОС Windows.

Флаги, реализованные начиная с версии ядра Linux 2.2:

• MSG_DONTWAIT — включить неблокирующий режим. Если операция блокируется, возвращается EAGAIN или EWOULDBLOCK. Похож на флаг O_NONBLOCK, но их различие в том, что MSG_DONTWAIT является флагом для отдельного вызова.

• MSG_EOR — завершить запись, например, для сокетов типа SOCK_SEQPACKET. В доменах SOCK_STREAM и SOCK_DGRAM нет протоколов, его поддерживающих, но они есть в доменах SOCK_SEQPACKET, например Bluetooth, IrDA, X.25.

• MSG_NOSIGNAL — отключить генерацию сигнала SIGPIPE, если удаленный абонент закрыл соединение. Аналогично установке флага через sigaction(), но для отдельного вызова.

Начиная с версии ядра Linux 2.3.15:

• MSG_CONFIRM — сообщает канальному уровню, что пересылка состоялась, то есть получен успешный ответ от удаленного абонента. Если канальный уровень не получает его, он регулярно перепроверяет сеть, например, посредством однонаправленной передачи ARP.

Начиная с версии Linux 2.4.4:

• MSG_MORE — у вызывающего абонента есть данные на передачу. Флаг используется с TCP-сокетами в тех же целях, что и параметр сокета TCP_CORK, с той разницей, что этот флаг может быть установлен для отдельного вызова. Начиная с версии ядра 2.6 поддерживается также и для UDP. В этом случае он используется в том числе для упаковки всех данных в одну дейтаграмму, которая будет отправлена первым вызовом sendto() без этого флага. Для Unix-сокетов эта опция не работает.

Прочие флаги:

• MSG_ZEROCOPY — использовать механизм без копирования данных между ядром и пользовательским пространством. Данный механизм необходимо включать через опцию SO_ZEROCOPY, которая описана в главе 8.

Видим, что флаги были реализованы еще в достаточно старых версиях Linux. В новых версиях ядра могут добавляться новые флаги.

В разных ОС существуют и достаточно экзотические флаги. Например в IBM AIX 7.2 есть флаг MSG_MPEG2, который указывает, что данные представляют собой блок видеопотока MPEG2.

Весь набор методов класса socket.socket в Python, соответствующих sendto()/recvfrom(), выглядит так:

# Тип буфера, в который может быть произведена запись.

# ReadableBuffer имеет схожее представление.

WriteableBuffer = collections.abc.Buffer

def recvfrom(self, bufsize: int, flags: int) -> tuple[bytes, _RetAddress]

def recvfrom_into(self, buffer: WriteableBuffer, nbytes: int, flags: int) -> tuple[int, _RetAddress]

@overload

def sendto(self, data: ReadableBuffer, address: _Address) -> int

@overload

def sendto(self, data: ReadableBuffer, flags: int, address: _Address) -> int

Видны следующие особенности:

• Метод sendto() перегружен, чтобы его сигнатура была такой же, как у функции C API. Вторая сигнатура позволяет вызывать его без флагов.

• Метод recvfrom_into() позволяет записывать принятые данные в предварительно созданный буфер, что соответствует функции recvfrom() C API.

Буфером для вызова recvfrom_into() может являться bytearray, memoryview, массив, различные C-типы и прочие реализации буферного интерфейса:

>>> import socket

>>> s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

>>> s.bind(('', 12345))

>>> b = bytearray(5)

>>> s.recvfrom_into(b)

(5, ('127.0.0.1', 53622))

>>> print(b)

bytearray(b'12345')

Было отправлено 7 байт, но выше был выделен bytearray размером 5 байт, которые и были приняты и скопированы в него из дейтаграммы, «1234567» — ввод с клавиатуры:

➭ nc -u localhost 12345

1234567

Работа по UDP

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

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

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

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

Решением служат сложные протоколы, такие как TCP. О разработке своих протоколов мы поговорим в книге 2.

Эхо-сервер поверх UDP

Рассмотрим код сервера, пример работы которого показан на рис. 4.1. Он просто возвращает клиенту полученный запрос.

Рис. 4.1. Работа UDP эхо-сервера

Весь код реализуем в функции main(). Сначала создадим сокет и привяжем к нему адрес:

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

{

...

socket_wrapper::SocketWrapper sock_wrap;

// Создать новый сокет.

socket_wrapper::Socket sock = { AF_INET, SOCK_DGRAM, IPPROTO_UDP };

if (!sock)

{

std::cerr << "Socket creation error!" << std::endl;

return EXIT_FAILURE;

}

auto addrs = socket_wrapper::get_serv_info(argv[1], SOCK_DGRAM);

// Привязать к сокету адрес.

if (bind(sock, addrs->ai_addr, addrs->ai_addrlen) != 0)

{

std::cerr << "Bind error!" << std::endl;

return EXIT_FAILURE;

}

std::cout << "Starting echo server on the port " << argv[1] << "...\n";

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

Сначала подготовим буферы:

std::array<char, 256> buffer;

// Сюда будет сохранен адрес клиента.

sockaddr_in client_address = {0};

socklen_t client_address_len = sizeof(sockaddr_in);

std::array<char, INET_ADDRSTRLEN> client_address_buf;

std::cout << "Running echo server...\n" << std::endl;

В цикле выполним прием и отправку данных:

while (true)

{

// Принять то, что отправил клиент, в buffer.

// Адрес клиента будет записан в client_address.

const ssize_t recv_len = recvfrom(sock, buffer.data(),

buffer.size() – 1, 0,

reinterpret_cast<sockaddr *>(&client_address),

&client_address_len);

if (recv_len > 0)

{

buffer[recv_len] = '\0';

std::cout

<< "Client with address "

<< inet_ntop(AF_INET, &client_address.sin_addr,

client_address_buf.data(), client_address_buf.size())

<< ":" << client_address.sin_port

<< " sent datagram [length = " << recv_len << "]:\n'''\n"

<< buffer << "\n'''"

<< std::endl;

// Тут можно реализовать обработку команд.

// Отправить принятые ранее данные в ответ клиенту.

sendto(sock, buffer.data(), recv_len, 0,

reinterpret_cast<const sockaddr *>(&client_address),

client_address_len);

}

else if (recv_len < 0) perror("recvfrom");

std::cout << std::endl;

}

return EXIT_SUCCESS;

}

Запустим сервер, а затем, используя Netcat, подключимся и отправим сообщение:

➭ ncat -u4 localhost 12345

Test?

Test?

Видим, что сервер отправляет принятое сообщение. Посмотрим, как это выглядит с его стороны:

➭ build/bin/b01-ch04-udp-server 12345

Starting echo server on the port 12345...

Running echo server...

Client with address 127.0.0.1:48185 sent datagram [length = 6]:

'''

Test?

'''

Если требуется обрабатывать команды, из строки, принимаемой сервером, необходимо удалить символы перевода строки и возможные концевые пробелы. В Perl для этого использовалась небезызвестная функция chomp(), в Python есть метод строки rstrip(), а в C++ эту функцию приходится реализовывать самостоятельно:

// Убрать «непечатные» символы в конце строки (in place).

static inline void rtrim(std::string &s)

{

s.erase(std::find_if(s.rbegin(), s.rend(),

std::not1(std::ptr_fun<int, int>(std::isspace))).base(), s.end());

}

Она нужна из-за того, что netcat передает завершающий перевод строки, а он, как правило, не требуется.

На Python код будет выглядеть немного лаконичнее:

import socket

# Необходимо задать достаточный размер буфера, хотя он выделяется автоматически.

# Эта нехарактерная для Python особенность пришла из C.

buffer_size = 255

try:

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

# В Python порт будет автоматически преобразован к сетевому

# порядку байтов.

s.bind(('0.0.0.0', 8080))

while True:

# Прием данных и получение адреса.

data, client_address = s.recvfrom(buffer_size)

if data:

# Отправка принятых данных.

s.sendto(data, client_address)

except Exception as e:

print(f'Socket error: {e}')

В результате получаем очень простой эхо-сервер, работающий поверх UDP.

Эхо-сервер можно создать и без написания кода, используя стандартную утилиту Netcat:

➭ ncat -4 --exec /bin/cat --listen --udp 1230

По этой команде Netcat запустит /bin/cat, начнет прослушивать UDP-порт 1230 и ждать прихода данных. Порядок дальнейших действий следующий:

1. По прибытии данные попадут на стандартный ввод утилиты cat.

2. Она перенаправит их на стандартный вывод.

3. Стандартный вывод будет отправлен в сеть как ответ.

После того как клиент подключится к UDP-порту 1230 и отправит данные, Netcat выведет их на экран:

➭ ncat -4 --udp localhost 1230

test echo server

test echo server

Первое сообщение было введено с консоли. Второе сообщение, test echo server, пришло как ответ сервера и было напечатано Netcat.

Raw-сокеты

Иногда в доменах INET и INET6 требуется отправлять и получать IP-пакеты без использования протокола транспортного уровня либо с бо́льшим контролем над сетевым стеком, чем это позволяет API.

Например:

• Для чтения заголовков протокола. При чтении из необработанного сокета заголовки обычно включаются.

• Для реализации некоторых протоколов в пользовательском пространстве. Но повторить, например, протокол TCP не получится: диспетчеризацию пакетов по порту обеспечивает стек ядра.

• С целью перехвата трафика определенного типа.

• В сканерах для отправки исходящих TCP-сегментов и UDP-дейтаграмм или подделки IP-адреса.

• Для использования протоколов маршрутизации и управления, таких как протокол управления группами интернета — IGMP, протокол открытия кратчайшего пути — OSPF или протокол управляющих сообщений интернета — ICMP, используемый утилитой ping.

Для выполнения таких задач существуют raw-сокеты, то есть «сырые» или «необработанные», поскольку они пропускают часть обработки стеком, как показано на рис. 4.2.

Рис. 4.2. Raw-сокет

При использовании raw-сокетов работают возможности IP, например выполняется сборка данных из фрагментов.

Иногда raw-сокеты называют символьными сокетами.

Большинство API-сокетов, например основанные на сокетах Беркли, поддерживают raw-сокеты.

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

Так, в разных версиях Windows raw-сокеты поддерживаются с разными ограничениями. Windows XP в 2001 году была выпущена с их поддержкой, но затем Microsoft ограничила возможности «из соображений безопасности».

PF_PACKET

Raw-сокеты не самые низкоуровневые. В Linux, например, поддерживается семейство PF_PACKET, сокеты которого позволяют отправлять необработанные пакеты напрямую драйверу сетевого устройства, то есть работают поверх 2-го уровня OSI, тогда как raw-сокеты могут работать лишь частично поверх уровня 3.

Подробнее о них см. man 7 packet.

Внимание! Код, использующий raw-сокеты, не очень хорошо переносится между операционными системами.

Создание

Для создания raw-сокета используется константа типа SOCK_RAW, например:

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

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

Значения протокола также могут быть разными: IPPROTO_TCP, IPPROTO_UDP, IPPROTO_ICMP, IPPOROTO_IGMP, IPPROTO_RAW. Может использоваться и любое числовое значение — оно будет подставлено в соответствующее поле заголовка IP-пакета. В примере выше указан протокол IPPROTO_ICMP, но ICMP-заголовки созданы не будут. Указание IPPROTO_ICMP повлечет следующие действия:

• При отправке IP-пакета стек ОС установит код вышележащего протокола в IP-заголовке, если не включена опция IP_HDRINCL.

• В ядре Linux будут увеличиваться счетчики исходящих ICMP-пакетов, которые используются в SNMP-статистике. К такому результату приведет его явное указание в заголовке.

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

Существует несколько специальных значений для raw-сокетов:

• IPPROTO_IP или 0 — в Linux будет получена ошибка EPROTONOSUPPORT — «протокол не поддерживается». Во FreeBSD и MacOS X, например, такой сокет будет принимать все IP-пакеты, а на передачу работать как «обычный» raw-сокет, за тем исключением, что в IP-заголовке протокол будет иметь значение 0, если пользователь не передает свой заголовок. В Windows данный параметр будет работать так же, как во FreeBSD.

• IPPROTO_RAW или 255 — протокол не указан. В Linux пользователь всегда должен передавать IP-заголовок с указанным протоколом, то есть не 255. Во FreeBSD, если пользователь этого не сделал, в поле IP-заголовка будет подставлено значение 255.

Прием данных

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

Отправка данных

IP-заголовки при отправке могут создаваться как ядром, так и вручную.

Ядро полностью генерирует IP-заголовок при отправке пакета, если для сокета не включена опция сокета IP_HDRINCL. Включение этой опции означает, что пользователь сам включает IP-заголовок в пакет. В этом случае указанное в параметрах функции socket() значение протокола подставлено не будет и пользователь должен сам установить значение протокола в заголовке.

В Linux опция включается автоматически, если в качестве протокола в функции socket() использовать константу IPPROTO_RAW. Во FreeBSD, наоборот, опция не будет включена по умолчанию. Если в приложениях с raw-сокетами вы создаете собственный IP-заголовок, лучше всегда устанавливать IP_HDRINCL явно.

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

• Total Length и Checksum всегда заполняются автоматически.

• Source Address и Packet ID будут заполнены, если они нулевые. Данное поведение характерно для Linux. Стандартами оно не покрывается.

Внимание! При включенной в Linux опции IP_HDRINCL IP-пакет не будет фрагментирован и будет отброшен сетевым адаптером, если его размер превышает MTU.

ICMP

ICMP, или Internet Control Message Protocol, — сетевой протокол, который используется в служебных целях. Описан в RFC 792 «Internet Control Message Protocol».

Его основные задачи:

• Передача сообщений об ошибках при нахождении ошибок в IP-заголовках.

• Сообщения при отсутствии маршрута к адресату, отправляемое маршрутизаторами.

• Обновления записей в таблицах маршрутизации отправителя данных.

• Управление скоростью отправки сообщений отправителем.

• Проверка доступности узлов, используемая утилитой Ping.

• Отображение пути следования IP-пакетов, реализованное в утилите Traceroute.

ICMP-сообщение отправляется поверх IP, оно имеет структуру, изображенную на рис. 4.3.

Рис. 4.3. Структура ICMP-пакета

Тип и код определяют, для чего предназначено ICMP-сообщение. Например, утилитой Ping используется тип 8, код 0 — эхо-запрос для проверки доступности узла. Если узел доступен, то узлу, пославшему запрос, он вернет эхо-ответ с типом 0 и кодом 0.

Видим, что портов в данном протоколе нет, так как пакет обрабатывает сетевой стек, и конкретному процессу пакет не приходит. Например, если взять реализованную ниже утилиту ping и убрать отправку сообщений и установку тайм-аута через опцию SO_RCVTIMEO, то процесс будет ожидать пакеты на raw-сокете. Естественно, приходить они не будут, так как эхо-запрос не отправлен. Если же отправить эхо-запрос из любого процесса, например системной утилиты, эхо-ответ поступит ко всем процессам, читающим из raw-сокета через recvfrom().

Размер в заголовке также отсутствует, но в IP-заголовке существует поле Total Length, которое определяет полную длину всего IP-пакета, а длина заголовка ICMP фиксированна, поэтому узнать, сколько данных передано в ICMP-пакете, несложно.

Код протокола — IPPROTO_ICMP = 1.

ICMPv6, используемый поверх IPv6, имеет такую же структуру заголовка и отличается тем, что применяется только с IPv6, а также составом списка сообщений, которые он может передавать. Значения полей «Тип» и «Код» от ICMP отличаются.

Его код в поле Next Header IPv6-пакета — IPPROTO_ICMPV6 = 58.

Утилита ping

Ping — утилита для проверки целостности и качества соединений в сетях на основе TCP/IP. Названа так от английского обозначения звука импульса, издаваемого сонаром.

Ping является одним из основных диагностических средств в сетях TCP/IP и входит в поставку всех современных сетевых операционных систем.

Утилита Ping — характерный пример работы raw-сокетов. Как показано на рис. 4.4, для своей работы она использует ICMP.

Рис. 4.4. Работа утилиты Ping

Утилита отправляет запросы ICMP Echo Request c типом 8 и кодом 0 указанному узлу сети и фиксирует поступающие ответы ICMP Echo Reply с типом 0 и кодом 0. Время между отправкой запроса и получением ответа — RTT — позволяет определять двусторонние задержки по маршруту и частоту потери пакетов, то есть косвенно определять загруженность канала и качество передачи данных в канале и промежуточных устройствах.

Обычный эхо-запрос имеет длину 64 байта плюс 20 байт IP-заголовка. По стандарту RFC 791 «Internet Protocol» суммарный объем пакета не может превышать 65 535 байт.

Поскольку для отправки ICMP-сообщений необходимо создавать Raw-сокеты, выполнение программы ping в UNIX-системах требует прав суперпользователя. Чтобы обычные пользователи могли использовать ping, в правах доступа файла /bin/ping устанавливают SUID-бит либо реализуют это, используя привилегии CAP_NET_RAW.

В современном Linux утилита ping работает немного по-другому, не требуя привилегий. Об этом будет рассказано чуть позже.

Внимание! Реализованный нами ping работает с правами суперпользователя напрямую либо используя sudo.

Алгоритм работы Ping:

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

2. Выполнить разрешение имени узла в IP-адрес.

3. Открыть Raw-сокет, используя тип SOCK_RAW с протоколом IPPROTO_ICMP. Raw-сокет требует прав суперпользователя, поэтому код нужно запускать из-под root, например, с помощью sudo.

4. Установить тайм-аут функции recv(). Если тайм-аут не установлен, recv() будет ждать бесконечное время. Рабочий цикл будет остановлен. Нам же требуется определенное время ожидания, после которого recv() завершится, если пакет не пришел.

5. Запустить рабочий цикл.

Тайм-аут устанавливается через вызов:

setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,

reinterpret_cast<const char*>(&tv), sizeof(tv));

В Linux tv — структура типа timeval, в Windows — DWORD. Вызов setsockopt() будет подробно описан в главе 8.

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

Теперь рассмотрим рабочий цикл Ping.

1. Заполнить пакет ICMP:

а) установить тип заголовка пакета в ICMP_ECHO;

б) установить код в 0 — эхо-запрос;

в) опционально установить ID равным PID текущего процесса;

г) заполнить данные сообщения, например, случайным образом;

д) подсчитать контрольную сумму и записать ее в поле контрольной суммы пакета.

2. Отправить пакет.

3. Подождать получения пакета ответа. Если пакет не получен, это означает, что с узлом возникли проблемы или он сконфигурирован не отвечать на пинг.

4. Эхо-ответ означает, что пункт назначения в порядке. Такой ответ отправляет ядро ОС целевого узла.

5. Убрать заголовок IP-дейтаграммы.

6. Проверить контрольную сумму и поля ответа.

7. Вывести данные о пакете, как минимум время оборота.

Полностью алгоритм работы простейшей утилиты Ping показан на рис. 4.5.

Иногда ping, например google.com, дает странный адрес типа bom07s18-in-f14.1e100.net. Это результат обратного поиска в DNS. Он выполняется с помощью уже известной нам функции getnameinfo(), которая преобразует IP-адрес из точечной нотации в имя узла.

Ping из состава дистрибутивов Linux значительно сложнее, выполняет гораздо большее число задач и поддерживает IPv6: https://github.com/iputils/iputils/blob/master/ping.

Внимание! Вместо пакета эхо-ответа с типом 0 и кодом 0 вы можете получить странный результат с типом 69. Причина в том, что некоторые системы, в частности Linux, не удаляют заголовок IP-пакета:

• 69 — это 0x45;

• 4 — версия IP;

• 5 — минимально допустимое значение его поля IHL, которое означает длину IP-дейтаграммы в 32-битных словах. Если перевести ее в байты, получим: 5 * 4 = 20. Это и есть смещение в байтах для начала ICMP-пакета.

Рис. 4.5. Простой ping

Ping на C++

Рассмотрим, как реализован ping на C++:

using namespace std::chrono_literals;

//

// Константы, относящиеся к пакету.

//

// Размер пакета.

constexpr size_t ping_packet_size = 64;

// Ожидание между пингами.

constexpr auto ping_sleep_rate = 1000000us;

// Тайм-аут ответа.

constexpr auto recv_timeout = 1s;

// Код эхо-запроса.

constexpr int ICMP_ECHO = 8;

// Код эхо-ответа.

constexpr int ICMP_ECHO_REPLY = 0;

Все приведенные выше константы очевидны, и заострять внимание на них нет смысла. Поэтому рассмотрим структуры и заголовки, описывающие заголовок ICMP-пакета:

// Поля структуры, отправляемой в сеть, не должны быть выровнены.

#pragma pack(push, 1)

// В Windows этой структуры нет, а у похожей структуры другие поля.

struct icmphdr

{

// Тип сообщения.

uint8_t type;

// Код сообщения.

uint8_t code;

// Контрольная сумма.

uint16_t checksum;

// Данные сообщения зависят от типа и кода.

union

{

// Структура Echo дейтаграммы.

struct

{

uint16_t id;

uint16_t sequence;

} echo;

// Адрес шлюза.

uint32_t gateway;

// Структура Path MTU Discovery.

struct

{

uint16_t __unused;

uint16_t mtu;

} frag;

} un;

};

И IP-заголовок:

//

// Заголовок IPv4 без опций.

//

typedef struct ip_hdr

{

// Версия – 4 бита, длина заголовка в 32-битных словах – 4 бита.

unsigned char ip_verlen;

// IP Type of Service.

unsigned char ip_tos;

// Полная длина.

uint16_t ip_totallength;

// Уникальный идентификатор.

uint16_t ip_id;

// Смещение фрагмента.

uint16_t ip_offset;

// Time To Live, или TTL – время жизни пакета.

unsigned char  ip_ttl;

// Протокол: TCP, UDP и т.д.

unsigned char  ip_protocol;

// Контрольная сумма.

uint16_t ip_checksum;

// Адрес источника.

uint32_t ip_srcaddr;

// Адрес назначения.

uint32_t ip_destaddr;

} IPV4_HDR, *PIPV4_HDR;

#pragma pack(pop)

Для упрощения IP-пакеты, содержащие опции, мы не обрабатываем.

Обратите внимание, что все структуры должны быть упакованы, для чего используется директива pragma pack(push, 1). Она указывает компилятору не выравнивать поля данной структуры по границе слова, то есть в структуре не будет «дырок», хотя доступ к полям будет менее эффективен.

Теперь рассмотрим класс, инкапсулирующий ICMP-пакет. Сначала опишем конструкторы:

// Класс, экземплярами которого служат запросы и ответы ICMP Echo.

class PingPacket

{

public:

using BufferType = std::vector<char>;

public:

// Конструктор неинициализированного пакета.

// Используется для recvfrom().

explicit PingPacket(size_t packet_size = ping_packet_size) :

data_buffer_(packet_size, 0)

{

assert(data_buffer_.size() >= sizeof(icmphdr));

}

explicit PingPacket(uint16_t packet_id, uint16_t packet_sequence_number,

size_t packet_size = ping_packet_size)

{

create_new_packet(packet_id, packet_sequence_number, packet_size);

}

explicit PingPacket(BufferType &&packet_buffer) :

data_buffer_{packet_buffer}

{

assert(data_buffer_.size() >= sizeof(icmphdr));

}

explicit PingPacket(const BufferType::const_iterator &start,

const BufferType::const_iterator &end) :

data_buffer_{start, end}

{

assert(data_buffer_.size() >= sizeof(icmphdr));

}

Добавим методы получения заголовка, размера, контрольной суммы и преобразования в тип «сырого» буфера:

// Получение структуры ICMP-заголовка из буфера пакета.

const icmphdr &header() const

{

return *get_header_from_buffer();

}

size_t size()

{

return data_buffer_.size();

}

// Расчет контрольной суммы требуется для проверки корректности ответа.

uint16_t checksum() const

{

const uint16_t *buf = reinterpret_cast<const uint16_t*>(

data_buffer_.data());

size_t len = data_buffer_.size();

uint32_t sum = 0;

for (sum = 0; len > 1; len -= 2) sum += *buf++;

if (1 == len) sum += *reinterpret_cast<const uint8_t*>(buf);

sum = (sum >> 16) + (sum & 0xffff);

sum += (sum >> 16);

uint16_t result = sum;

return ~result;

}

Оператор для приведения к булеву типу проверяет контрольную сумму, остальные приводят буфер к нужным типам для простоты использования:

public:

operator BufferType() const

{

return data_buffer_;

}

operator const BufferType::value_type*() const

{

return data_buffer_.data();

}

operator BufferType::value_type*()

{

return data_buffer_.data();

}

operator bool() const

{

auto real_header = const_cast<icmphdr*>(get_header_from_buffer());

auto old_checksum = real_header->checksum;

// Перед расчетом контрольной суммы необходимо обнулить поле.

real_header->checksum = 0;

auto new_checksum = checksum();

// Восстановить поле.

real_header->checksum = old_checksum;

return old_checksum == new_checksum;

}

Теперь опишем закрытую часть. Метод get_header_from_buffer() просто возвращает указатель на пакет:

private:

icmphdr *get_header_from_buffer() const

{

assert(!data_buffer_.empty());

return reinterpret_cast<icmphdr*>(

const_cast<BufferType::value_type*> (data_buffer_.data()));

}

Наиболее важным является метод, создающий эхо-запрос ICMP:

// Создание нового запроса ICMP Echo.

void create_new_packet(uint16_t packet_id,

uint16_t packet_sequence_number,

size_t packet_size)

{

static_assert(1 == sizeof(BufferType::value_type));

assert(packet_size > sizeof(icmphdr));

data_buffer_.resize(packet_size);

// Указатель на буфер.

auto p_header = get_header_from_buffer();

p_header->type = ICMP_ECHO;

p_header->code = 0;

p_header->checksum = 0;

// Все должно быть в сетевом порядке.

p_header->un.echo.id = htons(packet_id);

p_header->un.echo.sequence = htons(packet_sequence_number);

// Данные пакета – повторяющиеся символы от a до z.

std::generate(std::next(data_buffer_.begin(), sizeof(icmphdr)),

data_buffer_.end(),

[i = 'a']() mutable

{

return i <= 'z' ? i++ : i = 'a';

}

);

// В завершение – расчет контрольной суммы.

get_header_from_buffer()->checksum = checksum();

}

private:

BufferType data_buffer_;

};

Он выделяет буфер для пакета, заполняет его поля и заполняет данные пакета шаблоном: повторяющимися символами от a до z. После чего рассчитывает и записывает контрольную сумму пакета.

Основные задачи класса:

• Хранение буфера пакета.

• Разбор заголовка.

• Вычисление контрольной суммы и ее проверка.

• Формирование нового пакета для отправки.

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

Пакеты формируются классом, реализующим фабрику:

//

// Фабрика запросов и ответов.

//

template<class PClass>

class PacketFactory

{

public:

typedef PClass PacketClass;

static constexpr uint16_t max_id = 2 ^ (8 * sizeof(uint16_t)) – 1;

public:

PacketFactory() : pid_(getpid()), sequence_number_{0} {}

public:

PacketClass create_request()

{

// Новый запрос.

return PacketClass(pid_, sequence_number_++);

}

PacketClass create_response()

{

return PacketClass();

}

// Создать объект пакета ответа по итераторам.

PacketClass create_response(

const typename PacketClass::BufferType::iterator start,

const typename PacketClass::BufferType::iterator end)

{

return PacketClass(start, end);

}

// Создать объект пакета ответа из буфера.

PacketClass create_response(typename PacketClass::BufferType &&buffer)

{

return PacketClass(std::move(buffer));

}

private:

int pid_{0};

uint16_t sequence_number_{0};

};

Метод create_request() создает новый запрос, причем номер его последовательности хранит фабрика, а методы create_response() создают из буфера с данными объекты PingPacket для ответов.

Функция send_ping() выполняет основную работу. Сначала внутри нее рассчитывается тайм-аут:

void send_ping(const socket_wrapper::Socket &sock,

const std::string &hostname,

const struct sockaddr_in &host_address,

bool ip_headers_enabled = true)

{

using namespace std::chrono;

socket_wrapper::SocketWrapper sock_wrap;

#if !defined(WIN32)

// Для Unix-like и Windows формат метки времени разный.

timeval tv =

{

.tv_sec = std::chrono::duration<long>(

duration_cast<seconds>(recv_timeout)).count(),

// Рабочий хак.

.tv_usec = (long)(duration_cast<microseconds>(recv_timeout) – \

microseconds(duration_cast<seconds>(recv_timeout))).count()

};

#else

auto tv = duration_cast<milliseconds>(recv_timeout).count();

#endif

Затем устанавливаются опции времени ожидания и максимальное значение TTL на сокет:

sockaddr_in r_addr;

PacketFactory<PingPacket> ping_factory;

constexpr int ttl_val = 255;

// Установка значения TTL на сокет.

if (setsockopt(sock, IPPROTO_IP, IP_TTL,

reinterpret_cast<const char*>(&ttl_val), sizeof(ttl_val))

!= 0)

{

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

std::system_category(),

"TTL setting failed!");

}

// Установка тайм-аута для функции recvfrom для сокета.

if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,

reinterpret_cast<const char*>(&tv), sizeof(tv)) != 0)

{

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

std::system_category(),

"Recv timeout setting failed!");

}

std::cout

<< "TTL = " << ttl_val << "\n";

#if !defined(WIN32)

std::cout

<< "Recv timeout seconds = " << tv.tv_sec << "\n"

<< "Recv timeout microseconds = " << tv.tv_usec

<< std::endl;

#else

std::cout

<< "Recv timeout seconds = " << tv << " ms\n"

<< std::endl;

#endif

После этого запускается цикл отправки запросов и приема ответов. Сначала сформируем и отправим запрос:

// Отправка ICMP-запросов в бесконечном цикле и прием ответов.

while (true)

{

// Создать запрос.

const auto request = ping_factory.create_request();

const auto &request_echo_header = request.header().un.echo;

std::cout

<< "Sending packet "

<< ntohs(request_echo_header.sequence)

<< " to \""

<< hostname

<< "\" "

<< "request with id = "

<< ntohs(request_echo_header.id)

<< std::endl;

// Отправить запрос.

if (sendto(sock, static_cast<const char*>(request), request.size(), 0,

reinterpret_cast<const sockaddr*>(&host_address),

sizeof(host_address)) < request.size())

{

std::cerr

<< "Packet sending failed: \""

<< sock_wrap.get_last_error_string()

<< "\"" << std::endl;

continue;

}

Затем примем ответ в буфер:

socklen_t addr_len = sizeof(sockaddr);

r_addr.sin_family = AF_INET;

r_addr.sin_addr = host_address.sin_addr;

const auto buf_size = ip_headers_enabled ? ping_packet_size +

sizeof(ip_hdr) : ping_packet_size;

// Буфер ответа.

std::vector<char> buffer(buf_size, 0);

const auto start_time = std::chrono::steady_clock::now();

// Принять ответ.

if (recvfrom(sock, buffer.data(), buffer.size(), 0,

reinterpret_cast<sockaddr*>(&r_addr),

&addr_len) < 0)

{

std::cerr

<< "Packet receiving failed: \""

<< sock_wrap.get_last_error_string()

<< "\"" << std::endl;

continue;

}

// Проверить контрольную сумму.

if (!response)

{

std::cerr << "Bad response checksum!" << std::endl;

}

// Функция получает размер всего IP-пакета.

const auto ip_header_len = [&buffer]()

{

return (reinterpret_cast<const ip_hdr *>(

buffer.data())->ip_verlen & 0x0f) * sizeof(uint32_t);

};

// Создать буфер ответа, при необходимости убрав IP-заголовок.

auto response = ip_headers_enabled ?

ping_factory.create_response(buffer.begin() + ip_header_len(),

buffer.end()) :

ping_factory.create_response(std::move(buffer));

Вспомогательная лямбда-функция ip_header_len() просто берет значение младшего полубайта из первого байта IP-заголовка и умножает его на размер слова, чтобы получить размер дейтаграммы в байтах.

Остается только проверить, что был принят именно ICMP-ответ, и если это так, разобрать его, а затем распечатать поля:

// В зависимости от параметров создания raw-сокета приходить могут не

// только эхо-ответы, поэтому нужна проверка.

if ((ICMP_ECHO_REPLY == response.header().type) &&

(0 == response.header().code))

{

const auto end_time = std::chrono::steady_clock::now();

const auto &response_echo_header = response.header().un.echo;

std::cout

<< "Receiving packet "

<< ntohs(response_echo_header.sequence)

<< " from \"" << hostname

<< "\" "

<< "response with id = "

<< ntohs(response_echo_header.id)

<< ", time = "

<< std::round(

std::chrono::duration_cast<

std::chrono::duration<double, std::milli>>

(

end_time – start_time

).count() * 100) / 100

<< "ms"

<< std::endl;

}

std::this_thread::sleep_for(ping_sleep_rate);

}

}

Наконец, функция main() выполняет инициализацию, разрешение имен, а в конце — запуск функции send_ping():

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

{

...

socket_wrapper::SocketWrapper sock_wrap;

const std::string host_name = { argv[1] };

// Получить адрес клиента.

auto addrs = socket_wrapper::get_client_info(host_name, 0, SOCK_DGRAM);

std::string addr_p(INET6_ADDRSTRLEN, 0);

auto si = reinterpret_cast<const sockaddr_in*>(addrs->ai_addr);

inet_ntop(addrs->ai_family, &si->sin_addr, addr_p.data(), addr_p.size());

std::cout

<< "Pinging \"" << host_name

<< "\" [" << addr_p << "]"

<< std::endl;

int sock_type = SOCK_RAW;

// Raw-сокет.

socket_wrapper::Socket sock = {AF_INET, sock_type, IPPROTO_ICMP};

if (!sock)

{

std::cerr

<< "Can't create raw socket: "

<< sock_wrap.get_last_error_string()

<< std::endl;

return EXIT_FAILURE;

}

else

{

std::cout << "Raw socket was created..." << std::endl;

}

std::cout << "Starting to send packets..." << std::endl;

// Начать отправку запросов.

send_ping(sock, host_name, addr, SOCK_RAW == sock_type);

}

Пример запуска:

➭ sudo build/bin/b01-ch04-ping-from-root google.com

Pinging "google.com" [142.250.74.46]

Raw socket was created...

Starting to send packets...

TTL = 255

Recv timeout seconds = 1

Recv timeout microseconds = 0

Sending packet 0 to "google.com" request with id = 22910

Receiving packet 0 from "google.com" response with id = 22910, time = 22.08ms

Sending packet 1 to "google.com" request with id = 22910

Receiving packet 1 from "google.com" response with id = 22910, time = 21.99ms

^C

В репозитории книги также имеется ping, не требующий для работы прав суперпользователя и привилегий: src/book01/ch04/cpp/ping. Как он работает, мы рассмотрим чуть ниже, используя для примера утилиту ping на Python.

Реализация на C++ под Windows

Пример, рассмотренный выше, является кросс-платформенным. Он будет работать на большинстве ОС, в том числе Windows. Однако для Windows существует специализированный API и библиотеки для работы с ICMP. Как ими пользоваться, будет показано в главе 18, посвященной Windows IP Helper API.

Ping на Python

На Python существует несколько пакетов, реализующих Ping, например pythonping и ping3. Рассмотрим пакет pythonping. Установим его и напишем простой скрипт:

from pythonping import ping

if '__main__' == __name__:

result = ping('google.com')

print(f'Ping:\n{result}')

Запустим ping:

➭ sudo ./run src/book01/ch04/python/pythonping_test.py

Ping:

Reply from 74.125.205.139, 29 bytes in 15.04ms

Reply from 74.125.205.139, 29 bytes in 14.77ms

Reply from 74.125.205.139, 29 bytes in 14.99ms

Reply from 74.125.205.139, 29 bytes in 14.84ms

Round Trip Times min/avg/max is 14.77/14.91/15.04 ms

Если выполнять запуск не под root, приложение завершится с ошибкой PermissionError: [Errno 1] Operation not permitted.

Нас интересует, как устроена библиотека. API библиотеки представлен функцией ping(). Внутри эта функция устанавливает некоторые опции, хранит идентификаторы отправленных сообщений, пока не пришли ответы, и запускает отправку/прием:

SEED_IDs = []

def ping(target, timeout=2, count=4, size=1, interval=0, payload=None,

sweep_start=None, sweep_end=None, df=False, verbose=False,

out=sys.stdout, match=False, out_format='legacy'):

"""Пинг удаленных хостов и обработка их ответов"""

# Генерация тела сообщения.

provider = payload_provider.Repeat(b'', 0)

if sweep_start and sweep_end and sweep_end >= sweep_start:

if not payload:

payload = random_text(sweep_start)

...

options = ()

if df:

options = network.Socket.DONT_FRAGMENT

while True:

# seed_id должен быть меньше или равен 65535.

# В оригинальном коде ping seed_id = getpid() & 0xFFFF.

seed_id = randint(0x1, 0xFFFF)

if seed_id not in SEED_IDs:

SEED_IDs.append(seed_id)

break

# Всю остальную работу выполняет объект Communicator.

comm = executor.Communicator(target, provider, timeout, interval,

socket_options=options, verbose=verbose,

output=out, seed_id=seed_id,

repr_format=out_format)

# Запуск ping.

comm.run(match_payloads=match)

SEED_IDs.remove(seed_id)

return comm.responses

За отправку и ожидание ответа отвечает класс Communicator, объект которого создается в функции ping():

class Communicator:

def __init__(self, target, payload_provider, timeout, interval,

socket_options=(), seed_id=None, verbose=False,

output=sys.stdout, repr_format=None):

...

def send_ping(self, packet_id, sequence_number, payload):

"""Отправить запрос ICMP Echo"""

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

# Объект класса ICMP добавит заголовок и правильно упакует структуру.

i = icmp.ICMP(icmp.Types.EchoRequest, payload=payload,

identifier=packet_id, sequence_number=sequence_number)

self.socket.send(i.packet)

return i

За ожидание пакетов отвечает метод:

def listen_for(self, packet_id, timeout, payload_pattern=None,

source_request=None):

"""Ожидать пакеты с указанным Id указанное время"""

time_left = timeout

response = icmp.ICMP()

while time_left > 0:

# Ожидать пакет.

raw_packet, source_socket, time_left =\

self.socket.receive(time_left)

# Распаковать пакет, если он не пустой.

# Распаковкой занимается также класс ICMP.

if raw_packet != b'':

response.unpack(raw_packet)

# Убедиться, что будет распакован неотправляемый пакет.

# RHEL также прослушивает исходящие пакеты.

if response.id == packet_id and response.message_type !=\

icmp.Types.EchoRequest.type_id:

...

# Вернуть ответ.

return Response(Message('', response, source_socket[0]),

timeout – time_left, source_request,

repr_format=self.repr_format)

# Ответ не получен.

return Response(None, timeout, source_request,

repr_format=self.repr_format)

Пинг запустит метод run():

@staticmethod

def increase_seq(sequence_number):

"""Увеличить номер последовательности ICMP"""

...

def run(self, match_payloads=False):

"""Выполняет Ping-запросы и сохраняет ответы"""

self.responses.clear()

identifier = self.seed_id

seq = 1

for payload in self.provider:

# Отправить эхо-запрос ICMP.

icmp_out = self.send_ping(identifier, seq, payload)

...

# Ждать ответ и добавить полученный ответ в список.

self.responses.append(self.listen_for(identifier, self.timeout,

icmp_out.payload, icmp_out))

seq = self.increase_seq(seq)

if self.interval:

time.sleep(self.interval)

Собственно, в этом классе формируется ICMP-пакет, заполняются его поля и вызывается метод отправки класса Socket. Дополнительно в данном классе производится управление счетчиком пакетов.

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

Класс Socket — обертка над сокетным интерфейсом, которая реализует некоторые функции более кросс-платформенно, например, такую специфику, как проверка тайм-аутов. Именно здесь создается реальный объект класса socket.socket:

class Socket:

# Значение опции для установки на сокет.

DONT_FRAGMENT = (socket.SOL_IP, 10, 1)

# Словарь для конвертации имени протокола в числовую константу.

PROTO_LOOKUP = {"icmp": socket.IPPROTO_ICMP, "tcp": socket.IPPROTO_TCP,

"udp": socket.IPPROTO_UDP, "ip": socket.IPPROTO_IP,

"raw": socket.IPPROTO_RAW}

def __init__(self, destination, protocol, source=None, options=(),

buffer_size=2048):

"""Создает новый сокет"""

try:

# Получить адрес хоста по имени.

self.destination = socket.gethostbyname(destination)

except socket.gaierror as e:

raise RuntimeError('Cannot resolve address "' + destination +

'", try verify your DNS or host file')

self.protocol = Socket.getprotobyname(protocol)

self.buffer_size = buffer_size

...

self.socket = socket.socket(socket.AF_INET, socket.SOCK_RAW,

self.protocol)

if options:

self.socket.setsockopt(*options)

# Потокобезопасный вариант getprotobyname().

@staticmethod

def getprotobyname(name):

try:

return Socket.PROTO_LOOKUP[name.lower()]

except KeyError:

raise KeyError("'" + str(name) +

"' is not in the list of supported proto types: "

+ str(list(Socket.PROTO_LOOKUP.keys())))

Его методы send() и receive() выполняют обмен данными:

def send(self, packet):

"""Отправка пакета в поток"""

self.socket.sendto(packet, (self.destination, 0))

def receive(self, timeout=2):

"""Ожидание пакетов до истечения тайм-аута"""

time_left = timeout

while time_left > 0:

start_select = time.perf_counter()

# Проверка сокета на поступление данных.

# Вызов select() подробнее будет рассмотрен в книге 2.

data_ready = select.select([self.socket], [], [], time_left)

elapsed_in_select = time.perf_counter() – start_select

time_left -= elapsed_in_select

if not data_ready[0]:

# Тайм-аут истек, но данных нет.

return b'', '', time_left

packet, source = self.socket.recvfrom(self.buffer_size)

return packet, source, time_left

def __del__(self):

...

self.socket.close()

Рассмотрим, как работает еще один пакет — Ping3:

from ping3 import ping, verbose_ping

if '__main__' == __name__:

# Возвращает RTT в секундах.

result = ping('google.com')

print(f'Ping: {result}')

verbose_ping('google.com', count=5)

Результат:

➭ ./run src/book01/ch04/python/ping3_test.py

Ping: 0.01501607894897461

ping 'google.com' ... 15ms

ping 'google.com' ... 15ms

ping 'google.com' ... 21ms

ping 'google.com' ... 23ms

ping 'google.com' ... 15ms

Включим отладку:

ping3.DEBUG = True

Видим, что если ping3 не может создать raw-сокет, он пытается создать дейтаграммный сокет и работать через него:

➭ ./run src/book01/ch04/python/ping3_test.py

[DEBUG] Ping3 Version: 4.0.3

[DEBUG] LOGGER: <Logger ping3 (DEBUG)>

[DEBUG] Function called: ping(google.com)

[DEBUG] `[Errno 1] Operation not permitted` when create socket.SOCK_RAW, using socket.SOCK_DGRAM instead.

[DEBUG] Function called: send_one_ping({'sock': <socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=1, laddr=('0.0.0.0', 0)>, 'dest_addr': 'google.com', 'icmp_id': 14609, 'seq': 0, 'size': 56})

[DEBUG] Destination address: 'google.com'

[DEBUG] Destination IP address: 142.250.74.142

[DEBUG] Sent ICMP header: {'type': 8, 'code': 0, 'checksum': 57594, 'id': 14609, 'seq': 0}

[DEBUG] Sent ICMP payload: b'A\xd8\xa0]\x9b\x07\xc1\x16QQQQQQQQQQQQQQQQQQQQQQQ

QQQQQQQQQQQQQQQQQQQQQQQQQ'

[DEBUG] Function returned: send_one_ping -> None

[DEBUG] Function called: receive_one_ping({'sock': <socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=1, laddr=('0.0.0.0', 15)>, 'icmp_id': 14609, 'seq': 0, 'timeout': 4})

[DEBUG] Unprivileged on Linux

Он использует дейтаграммный сокет с типом протокола ICMP.

Внимание! По крайней мере в Linux при использовании сокетов типа SOCK_DGRAM нельзя задать идентификатор в поле id. Его задает ОС, а содержимое заголовка не учитывается. Как правило, значения идентификаторов просто возрастают с каждым созданием такого сокета в системе.

Таким же образом реализован ping из пакета iputils-ping для современных Linux-систем. Из-за того что он не использует raw-сокеты, он не требует прав суперпользователя и других привилегий:

int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);

Увы, такой вариант будет работать не всегда, поэтому он предусматривает возможность обратного перехода на raw-сокеты.

Весь код пакета состоит из одного файла. Рассмотрим только основную функцию.

Сначала выполняется попытка создания raw-сокета, а при генерации исключения — дейтаграммного сокета:

def ping(dest_addr: str, timeout: int = 4, unit: str = "s",

src_addr: str = None, ttl: int = None, seq: int = 0,

size: int = 56, interface: str = None) -> float:

try:

# Попытка создать raw-сокет.

sock = socket.socket(socket.AF_INET, socket.SOCK_RAW,

socket.IPPROTO_ICMP)

except PermissionError as err:

# [Errno 1] Operation not permitted

if err.errno == errno.EPERM:

_debug("`{}` when create socket.SOCK_RAW, using socket.SOCK_DGRAM"

" instead.".format(err))

# Попытка создать дейтаграммный сокет.

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,

socket.IPPROTO_ICMP)

else:

# Эта ошибка не связана с недостатком привилегий.

raise err

Далее этот сокет используется для отправки ICMP-пакетов и чтения ответов.

Устанавливаются необходимые опции:

with sock:

if ttl:

# Далее все блоки try/except убраны.

try:

# IPPROTO_IP для Windows и BSD.

if sock.getsockopt(socket.IPPROTO_IP, socket.IP_TTL):

# Установка нового значения TTL для сокета.

sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl)

except OSError as err:

# Не получилось установить TTL.

# Далее весь отладочный вывод убран.

_debug("Set Socket Option `IP_TTL` in `IPPROTO_IP` Failed:"

"{}".format(err))

# Еще одна попытка установить TTL, уже используя другое

# значение ключа.

# Сокетный API не всегда кросс-платформенный, даже в Python.

# Это приходится обходить в коде библиотек.

if sock.getsockopt(socket.SOL_IP, socket.IP_TTL):

sock.setsockopt(socket.SOL_IP, socket.IP_TTL, ttl)

if interface:

# Указан интерфейс, с которого будут отправляться пакеты.

sock.setsockopt(socket.SOL_SOCKET, SOCKET_SO_BINDTODEVICE,

interface.encode())

if src_addr:

# Если был задан src_addr, будут приниматься только пакеты,

# которые были отправлены на этот адрес узла.

# Узел может иметь несколько сетевых адаптеров, каждому из

# которых может быть назначено 0 или более IP-адресов.

sock.bind((src_addr, 0))

thread_id = threading.get_native_id()

# Если ping запускается в разных процессах,

# идентификаторы потоков могут совпадать.

process_id = os.getpid()

# Чтобы избежать коллизий идентификатора icmp_id.

icmp_id = zlib.crc32("{}{}".format(process_id,

thread_id).encode()) & 0xffff

Отправляется запрос и читается ответ:

# Скомпоновать ICMP-запрос из ICMP-заголовка и тела, рассчитать

# контрольную сумму.

# Затем отправить этот запрос, используя sock.sendto(),

# на адрес dest_addr.

send_one_ping(sock=sock, dest_addr=dest_addr, icmp_id=icmp_id,

seq=seq, size=size)

# Ожидать ответа.

# Когда он придет:

#    – Обрезать IP-заголовок, если он есть.

#    – Проверить, что это ECHO_REPLY.

#    – Проверить, не содержит ли ICMP-заголовок ошибок.

#      В случае ошибки выбросить исключение.

#    – Рассчитать и вернуть RTT в секундах.

delay = receive_one_ping(sock=sock, icmp_id=icmp_id, seq=seq,

timeout=timeout)

if delay is None:

return None

if unit == "ms":

# В миллисекундах.

delay *= 1000

return delay

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

Ping на языке Go

Рассмотрим, как реализован ICMP-ping в таком языке, как Go, в котором сокетный интерфейс, как уже было показано, скрыт за стандартной библиотекой.

На Go также есть несколько библиотек, реализующих ping. Например, go-fastping, go-ping и подобные. В качестве примера возьмем Go-fastping. Создадим файл ping.go. В коде ниже происходит отправка ICMP-пакета и ожидание ответа на него. Если ответ приходит, вызывается функция OnRecv(), в которой обрабатывается успешно принятое сообщение.

Если превышено время ожидания MaxRTT, вызывается функция OnIdle():

package main

import "fmt"

import "net"

import "os"

import "time"

import "github.com/tatsushid/go-fastping"

func main() {

p := fastping.NewPinger()

ra, err := net.ResolveIPAddr("ip4:icmp", os.Args[1])

if err != nil {

fmt.Println(err)

os.Exit(1)

}

p.AddIPAddr(ra)

p.OnRecv = func(addr *net.IPAddr, rtt time.Duration) {

fmt.Printf("IP Addr: %s receive, RTT: %v\n", addr.String(), rtt)

}

p.OnIdle = func() {

fmt.Println("finish")

}

err = p.Run()

if err != nil {

fmt.Println(err)

}

}

Соберем модуль и запустим:

➭ cd src/book01/ch04/go && go get github.com/tatsushid/go-fastping

➭ sudo go run ping.go 127.0.0.1

IP Addr: 127.0.0.1 receive, RTT: 0s

finish

Рассмотрим основную часть библиотеки Go-fastping:

// Основная структура для управления библиотекой.

type Pinger struct {

// Размер пакета для отправки в байтах

Size int

// Тайм-аут ожидания в нано- или миллисекундах.

// После его истечения библиотека вызывает callback бездействия OnIdle.

// Он также используется для задания интервала метода RunLoop().

MaxRTT time.Duration

// OnRecv вызывается при успешном получении ответного пакета.

OnRecv func(*net.IPAddr, time.Duration)

// OnIdle вызывается по окончании тайм-аута MaxRTT.

OnIdle func()

// Если Debug – true, печатает больше информации о работе.

Debug bool

}

func (p *Pinger) run(once bool) {

p.debugln("Run(): Start")

var conn, conn6 *icmp.PacketConn

// Тут создается сокет с помощью библиотеки golang.org/x/net/icmp

// и необходимый сокет открывается на прослушивание

if p.hasIPv4 {

if conn = p.listen(ipv4Proto[p.network], p.source); conn == nil {

return

}

defer conn.Close()

}

...

Запускается горутина для приема ответов, затем выполняется отправка запроса:

// Канал, в который горутина запишет данные пакета.

recv := make(chan *packet, 1)

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

recvCtx := newContext()

// По сути, это семафор для ожидания пакета с ответом.

wg := new(sync.WaitGroup)

p.debugln("Run(): call recvICMP()")

if conn != nil {

wg.Add(1)

// Принять ответ для IPv4.

go p.recvICMP(conn, recv, recvCtx, wg)

}

if conn6 != nil {

wg.Add(1)

// Принять ответ для IPv6.

go p.recvICMP(conn6, recv, recvCtx, wg)

}

// Здесь производится отправка ICMP-запроса.

p.debugln("Run(): call sendICMP()")

queue, err := p.sendICMP(conn, conn6)

...

Все, что делает listen(), — это вызывает функцию ListenPacket():

func (p *Pinger) listen(netProto string, source string) *icmp.PacketConn {

// Вызов функции из библиотеки icmp.

conn, err := icmp.ListenPacket(netProto, source)

if err != nil {

// Обработка ошибки.

...

return nil

}

return conn

}

Рассмотрим более подробно, как создается сокет в библиотеке golang.org/x/net/icmp:

func ListenPacket(network, address string) (*PacketConn, error) {

var family, proto int

switch network {

case "udp4":

family, proto = syscall.AF_INET, iana.ProtocolICMP

...

// Создать дейтаграммный сокет и файловый дескриптор для

// него.

s, err := syscall.Socket(family, syscall.SOCK_DGRAM, proto)

if err != nil {

return nil, os.NewSyscallError("socket", err)

}

...

// Создать адрес сокета.

sa, err := sockaddr(family, address)

if err != nil {

syscall.Close(s)

return nil, err

}

// Связывание файлового дескриптора сокета

// и адреса сокета.

if err := syscall.Bind(s, sa); err != nil {

syscall.Close(s)

return nil, os.NewSyscallError("bind", err)

}

...

Прием ответов выполняется асинхронно в горутине, которая в цикле устанавливает таймер ожидания и принимает ответ. Если ответ пришел за указанное время, он записывается обратно в тот самый канал, из которого ранее был запрос:

func (p *Pinger) recvICMP(conn *icmp.PacketConn, recv chan<- *packet,

ctx *context, wg *sync.WaitGroup)

{

p.debugln("recvICMP(): Start")

for {

select {

case <-ctx.stop:

// Проверка на условие остановки цикла.

...

}

// Создать буфер для приема данных.

bytes := make([]byte, 512)

// Установить тайм-аут.

conn.SetReadDeadline(time.Now().Add(time.Millisecond * 100))

p.debugln("recvICMP(): ReadFrom Start")

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

_, ra, err := conn.ReadFrom(bytes)

p.debugln("recvICMP(): ReadFrom End")

if err != nil {

// Обработка ошибок, в том числе истекшего тайм-аута.

...

}

p.debugln("recvICMP(): p.recv <- packet")

select {

// Записать данные пакета с ответом в переданный канал.

case recv <- &packet{bytes: bytes, addr: ra}:

// Проверка на остановку цикла.

case <-ctx.stop:

...

wg.Done()

return

}

}

}

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

Первый ICMP-запрос выполняется путем вызова функции sendICMP(). Сначала функция отправки запроса формирует структуру и ее байтовое представление:

func (p *Pinger) sendICMP(conn, conn6 *icmp.PacketConn)

(map[string]*net.IPAddr, error) {

p.debugln("sendICMP(): Start")

p.mu.Lock()

// Установить поля запроса.

p.id = rand.Intn(0xffff)

p.seq = rand.Intn(0xffff)

p.mu.Unlock()

queue := make(map[string]*net.IPAddr)

wg := new(sync.WaitGroup)

for key, addr := range p.addrs {

var typ icmp.Type

var cn *icmp.PacketConn

// Установить правильный тип запроса и переменную соединения.

if isIPv4(addr.IP) {

typ = ipv4.ICMPTypeEcho

cn = conn

} else if isIPv6(addr.IP) {

typ = ipv6.ICMPTypeEchoRequest

cn = conn6

} else {

continue

}

...

// Создать структуру сообщения и маршализовать ее.

bytes, err := (&icmp.Message{Type: typ, Code: 0,

Body: &icmp.Echo{ID: p.id, Seq: p.seq,

Data: t,},}).Marshal(nil)

queue[key] = addr

// Сформировать адрес назначения.

var dst net.Addr = addr

if p.network == "udp" {

dst = &net.UDPAddr{IP: addr.IP, Zone: addr.Zone}

}

О формировании этого представления, которое выполняет метод Marshal(), мы поговорим в первых главах книги 3.

Вторая часть функции производит отправку данных:

p.debugln("sendICMP(): Invoke goroutine")

wg.Add(1)

// Запустить горутину для асинхронной отправки данных.

go func(conn *icmp.PacketConn, ra net.Addr, b []byte) {

for {

if _, err := conn.WriteTo(bytes, ra); err != nil {

// Если просто не хватает размера буфера сокета,

// продолжить попытки отправки.

if neterr, ok := err.(*net.OpError); ok {

if neterr.Err == syscall.ENOBUFS { continue }

}

}

break

}

p.debugln("sendICMP(): WriteTo End")

wg.Done()

}(cn, dst, bytes)

}

wg.Wait()

p.debugln("sendICMP(): End")

return queue, nil

}

Видим, что и здесь не все так просто. Данные отправляются в цикле, и в зависимости от кода ошибки выполняется несколько попыток отправки. Эти детали мы разберем позднее. Отправка данных повторяется в главном цикле по событию таймера:

mainloop:

for {

select {

...

// Необходимо для завершения цикла.

case <-recvCtx.done:

p.debugln("Run(): <-recvCtx.done")

...

break mainloop

case <-ticker.C:

...

p.debugln("Run(): call sendICMP()")

// Отправить пакет.

queue, err = p.sendICMP(conn, conn6)

case r := <-recv:

p.debugln("Run(): <-recv")

// Отвечает за разбор пришедшего ответа и вызов обработчиков.

p.procRecv(r, queue)

}

}

Там же выполняется разбор ответа, который функция recvICMP() записывает в канал.

Как и следовало ожидать, библиотека использует показанный ранее сокетный API.

Резюме

UDP и API для работы с ним очень просты. Однако сеть — это ненадежная среда. Поэтому в чистом виде UDP обычно не используют, а применяют как транспорт для более надежных протоколов.

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

Для отправки и приема дейтаграмм без установления соединения, в том числе по UDP, используются функции sendto() и recvfrom().

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

Для отправки и приема IP-пакетов без использования протокола транспортного уровня или для более глубокого контроля над сетевым стеком, чем позволяет API, можно применить raw-сокеты. Приложение, использующее raw-сокеты, получает IP-пакеты целиком, включая заголовки, но при отправке заголовки могут создаваться как ядром, так и вручную. Еще более низкоуровневыми являются сокеты типа AF_PACKET, но они доступны только в некоторых ОС, например в Linux.

raw-сокеты используются для реализации некоторых протоколов в пользовательском пространстве, для перехвата трафика в канале, а также в протоколах управления, например IGMP и ICMP.

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

На ICMP основана широко известная утилита Ping, которая является одним из основных инструментов диагностики в сетях TCP/IP и поставляется во всех современных сетевых операционных системах.

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

1. Какие сокеты проще в использовании: без соединения или ориентированные на соединение? В каких случаях и почему?

2. Какая функция используется для передачи данных на сокете без соединения? А для приема?

3. Какие методы класса socket.socket в Python предусмотрены для приема и передачи данных без соединения?

4. Когда используются потоковые сокеты, а когда — ориентированные на сообщения?

5. Как с помощью UDP гарантированно передать данные?

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

7. Чем клиент UDP отличается от сервера UDP?

8. Какие задачи могут решать raw-сокеты?

9. Какие преимущества предоставляют raw-сокеты по сравнению с обычными сокетами?

10. Являются ли raw-сокеты самыми низкоуровневыми?

11. Что делает опция IP_HDRINCL? В чем ее особенность?

12. Для чего используется ICMP?

13. Почему для работы ping в Unix-подобных системах обычно требуются права суперпользователя?

14. Как в Linux реализовать ping, работающий без прав суперпользователя?

15. Существуют ли особенности у Windows-реализации ping? Если да, то какие?

16. Дополните реализованный сервер обратным резолвом имени клиента.

17. Дополните реализованный сервер так, чтобы он принимал команду exit и при ее получении завершал работу.

18. Дополните реализованный сервер обработкой ошибок от recvfrom() и sendto().

19. Для примера из раздела об эхо-сервере поверх UDP напишите клиент на С++, который отправляет введенную пользователем строку данных.

Глава 5. Соединение. Потоковый обмен данными. Серверный API

Порядок освобождает мысль.

Рене Декарт

Введение

Пример из предыдущей главы с сервером был реализован через обмен сообщениями по UDP. Утилита ping — через обмен сообщениями напрямую, поверх IP. Эти протоколы работают по простому принципу «отправил и забыл».

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

Мы рассмотрим, какие есть транспортные протоколы и каковы различия между ними. Затем изучим серверный и клиентский API, необходимый для работы соединения, научимся передавать данные с помощью send() и recv(). Далее покажем типовые алгоритмы работы клиента и сервера для сокетов, ориентированных на соединение, и коснемся некоторых деталей, таких как прерывание сокетных функций сигналом в Unix-подобных ОС.

Что также важно, уделим время изучению правильного завершения соединения.

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

Сокеты, ориентированные на соединение

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

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

«Соединение» означает буквально следующее:

• Данные передаются до того момента, пока гарантированно не будут приняты.

• Данные передаются с сохранением порядка, и самое главное — приняты могут быть только данные, принадлежащие именно этому соединению и никакому другому.

• Служебные данные об этом соединении или состоянии соединения, определяемые протоколом, хранятся у каждого абонента.

Каждое соединение требует процедуры его установки. Соединение может быть разорвано, если данные не поступают слишком долго, что зависит от протокола. Часто используется механизм keep-alive — отправка пакетов по каналу для проверки того, что данные могут проходить.

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

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

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

TCP может работать, если потери не превышают 12 %. Для беспроводных сетей, например Wi-Fi, считается нормальным 0,6–2 % потерь, а процент выше 10 говорит о наличии проблем в сети.

При использовании сокетов, ориентированных на соединение, канал может быть установлен вызовом connect() на клиенте. После вызова connect() необходимости в использовании функций sendto() и recvfrom() нет, поскольку адреса уже зафиксированы в соединении.

Для обмена данными используются функции send() и recv(), не требующие указания адреса.

Сокеты, ориентированные на потоковый обмен

Важным отличием TCP от UDP является то, что TCP обеспечивает поток данных. Иными словами, отправка порции данных через send() не означает, что придет именно эта порция: данные могут буферизоваться на передающей или принимающей стороне и идти разными путями.

В случае UDP или SCTP, если вы последовательно отправите серверу слова «hello» и «exit», вам придет дейтаграмма, содержащая «hello», и дейтаграмма, содержащая «exit». В SCTP так произойдет, если данные отправлены в пределах одного потока.

В случае TCP вызов recv() может вернуть что угодно: как «hello» и «exit», так и «he», «llo», «exi», «t» или даже «helloexit». В общем случае невозможно предсказать, какой результат будет возвращен клиенту за один вызов приема данных.

Поэтому в случае TCP пользователь должен предусмотреть некий протокол верхнего уровня, используя который он может выделять порции данных. Например, определить символ начала и конца «записи». Или только конца, предполагая, что порция начинается с первого байта данных, полученных в сессии обмена.

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

Различия транспортных протоколов

Посмотрим на три разных протокола транспортного уровня:

Протокол/Особенность протокола

UDP

TCP

SCTP

Соединение, то есть хранение состояния у каждого абонента

нет

да

да

Надежная передача данных

нет

да

да

Сохранение границ сообщений

да

нет

да

Упорядоченная доставка

нет

да

да, в рамках потока

нет, между потоками

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

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

Различия между протоколами

Особенность протокола

UDP

TCP

SCTP

Управление потоком и перегрузками

нет

да

да

Выявление оптимального размера PDU — Path MTU discovery

нет

да

да

Пакетирование сообщений в поток — Message Bundling

нет

да

да

Поддержка множественной адресации узла — multi-homed hosts

нет

нет

да

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

нет

нет

да

Cookie безопасности для защиты от SYN-flood атак

нет

нет

да

Встроенная проверка доступности абонента — heart-beat/keep-alive

нет

нет

да

Из-за указанных различий для разных типов сокетов сокетный API, представленный в библиотеке, несколько различается. Однако обычно все они представлены вызовами send()/sendto(), recv()/recvfrom(), набором опций и некоторыми другими функциями, общими для всех типов сокетов.

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

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

Более подробно эти механизмы рассматриваются в литературе по сетям.

Часть функций может быть реализована в системной библиотеке, такой как LibC. Например, вызов sendto(). Часто он реализуется в библиотеке, а не в ядре, и если функция вызывается для TCP-сокетов, библиотека обеспечивает проверку адреса и вызов connect() при изменении адреса. Затем будем вызван обычный send().

С другой стороны, ради эффективности для многих функций можно использовать разные системные вызовы, как в Linux.

API сокетов, ориентированных на соединение

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

Установка соединения

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

Сервер обычно пассивно ожидает подключения, а клиент активно подключается к серверу. Хотя некоторые протоколы, например TCP, допускают взаимное установление соединения, на практике данная техника используется очень редко.

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

На рис. 5.1 видим, как отображается TCP-протокол на API, который подробно рассмотрим ниже.

Рис. 5.1. Клиент-серверное взаимодействие

Если соединение прошло успешно, будет выполнена процедура троекратного рукопожатия TCP:

1. Запрос SYN от клиента.

2. Сервер вернет SYN, ACK.

3. На что клиент снова ответит ACK.

После этого оба конца соединения будут открыты и готовы к обмену данными.

Если же сервер на данном адресе или порту не прослушивает, стек ОС вернет RST, ACK.

Функции клиента

Клиенту после определения адреса сервера необходимо вызвать функцию connect() на сокете. После этого он может начинать обмен данными, используя функции send() и recv().

Функция connect()

Устанавливает соединение для сокета, ориентированного на соединение. Для сокетов без соединения устанавливает или сбрасывает адрес.

Если применить connect() для сокетов, не ориентированных на соединение, например для UDP-сокета в PF_INET, соединение не установится, но произойдет следующее:

• Будет установлен адрес, с которого могут приходить дейтаграммы. Со всех других адресов они будут отбрасываться.

• Появится возможность использовать функции без указания адреса: send() и recv().

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

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

Функция объявлена в sys/socket.h или winsock2.h в Windows.

Прототип:

int connect(int socket, const struct sockaddr *address,

socklen_t address_len);

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

• socket — дескриптор сокета;

• address — адрес для подключения;

• address_len — длина структуры адреса: sizeof(address).

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

Сокеты в доменах UNIX и Internet, а также некоторых других могут разорвать ассоциацию путем подключения к адресу с членом sa_family в sockaddr, равным AF_UNSPEC.

После этого сокет может быть подключен к другому адресу. В Linux параметр AF_UNSPEC поддерживается, начиная с ядра 2.2.

Это верно именно для ассоциаций, например для UDP-сокетов. В случае же сокетов, которые реально ориентированы на подключения, будет получена ошибка «Transport endpoint is already connected».

Пример:

// Потоковый сокет и протокол – TCP.

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

const std::string host_name = "192.168.1.1";

auto addrs = socket_wrapper::get_client_info(host_name, "5000", SOCK_STREAM);

// Подключение к TCP-сокету по адресу 192.168.1.1 к порту 5000.

// connect() принимает структуру sockaddr.

if (connect(sock, addrs->ai_addr, addrs->ai_addrlen) != 0)

{

return EXIT_FAILURE;

}

Так как для адресов используются разные структуры, например sockaddr_in для IPv4 и sockaddr_in6 для IPv6, а функция connect() использует свой тип sockaddr, иногда требуется переопределение типа через reinterpret_cast.

В структуре addrinfo содержится поле ai_addr, которое имеет тип sockaddr и может быть передано в функцию без преобразования типов.

Для Python функция реализована как методы класса socket.socket:

def connect(self, address: _Address | bytes) -> None

def connect_ex(self, address: _Address | bytes) -> int

Первая в случае неудачи сгенерирует исключение ConnectionRefusedError: "[Errno 111] Connection refused". Вторая будет возвращать код ошибки. Не –1, как в C API, а именно код, записываемый C API в переменную errno, например 111 для ConnectionRefusedError.

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

with socket.socket(

socket.AF_INET,

socket.SOCK_STREAM,

socket.IPPROTO_TCP

) as s:

# Для IPv4 адрес – IP либо имя хоста и порт

s.connect(('google.com', 80))

print(s)

Будет выведен следующий результат:

<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('192.168.2.13', 60664), raddr=('173.194.222.100', 80)>

Видно, что соединение установлено успешно и заданы адрес и порт сервера и клиента.

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

Внимание! Поскольку в C-реализации метода для INET-сокетов выполняется разрешение адресов через inet_pton() и getaddrinfo(), обе функции могут генерировать исключения, например socket.gaierror и некоторые системные классы OSError.

Помимо этого, в Python есть функция create_connection(), вызывающая getaddrinfo() внутри себя:

def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT,

source_address=None, *, all_errors=False)

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

• address — адрес сервера для подключения. Представляет собой кортеж для IPv4 из адреса и порта.

• timeout — опциональный тайм-аут, превышение которого вызовет исключение, например OSError 101 «Network is unreachable». Если он не задан, функция будет ожидать неограниченное время.

• source_address — адрес сокета клиента. Если он задан, с ним будет выполнена привязка bind() на сокете клиента. Это полезно, если узел имеет несколько адресов, но сокет надо привязать только к одному из них. Например, обмен данными будет идти через сетевой интерфейс, соответствующий этому адресу.

• all_errors — если соединение нельзя установить, по умолчанию генерируется исключение для последнего адреса в списке. Если параметр истинный, будет сгенерирована группа исключений, содержащая все ошибки. Этот параметр появился в Python 3.11.

В большинстве случаев данную функцию удобно использовать для создания подключений.

Функции сервера

Алгоритм работы серверов, ориентированных на соединение, несколько сложнее, чем алгоритм реализованного нами ранее простого UDP-сервера.

Кроме того, API предполагает одновременную работу с большим количеством клиентов.

Создание простейшего сервера включает следующие шаги:

1. Создать новый сокет вызовом функции socket(). После этого будет создан объект ядра «сокет» и выбран протокол, который обеспечит взаимодействие.

2. Привязать сокет к прослушиваемому порту и адресу вызовом функции bind(). Для сокета устанавливается порт, который он будет прослушивать. Если узел имеет несколько сетевых адресов, сокет будет привязан к указанному.

3. Подготовить сокет к ожиданию соединений при помощи вызова listen(). Будет создана очередь входящих соединений, а на сокете установлен флаг ожидания соединений.

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

5. Обменяться данными с клиентом, используя send() и recv(). Эти функции будут работать с новым сокетом, который вернул accept(). Сокет, на котором был вызван listen(), будет ожидать новые соединения.

6. Закрыть сокет, связанный с клиентом, используя close()/closesocket().

Работа такого сервера показана на рис. 5.2.

Сначала вспомним, как используется ранее описанная функция getaddrinfo() для сервера.

Обратите внимание на флаги:

addrinfo hints =

{

// Данный флаг говорит о том, что нам требуются адреса, подходящие

// для функции bind().

// Он будет работать, так как здесь явно не задано поле ai_addr.

.ai_flags = AI_PASSIVE,

// Далее, как обычно, указываются: семейство, тип, протокол...

.ai_family = AF_INET,

.ai_socktype = SOCK_STREAM,

.ai_protocol = IPPROTO_TCP

};

addrinfo *s_i;

if (int ai_status = getaddrinfo(nullptr, port, &hints, &s_i); ai_status != 0)

{

throw std::logic_error(gai_strerror(ai_status));

}

Главное — указание флага AI_PASSIVE и отсутствие доменного имени, первого параметра функции getaddrinfo().

Внимание! Если передать имя узла вместе с флагом AI_PASSIVE, флаг будет проигнорирован, а функция сработает как для клиента.

Также полезно указать флаг AI_NUMERICSERV, если используется числовое представление службы, то есть номер порта. Подробности см. в главе 2.

Рис. 5.2. Клиент-серверный обмен

Далее рассмотрим функции, которые используются в реализации сервера.

Функция listen()

Создает очередь соединений для сокета и сигнализирует о том, что сервер готов принимать соединения. Именно после вызова данной функции можно выполнять TCP-подключения к серверу. Функция не блокирует выполнение программы. По сути, она выделяет очередь, выполняет некоторые проверки и помечает сокет как прослушивающий.

В ядре Linux, например, создание очереди соединений выполняется функцией inet_csk_listen_start(), вызываемой из inet_listen().

Функция объявлена в sys/socket.h или winsock2.h для Windows.

Прототип функции:

#include <sys/socket.h>

int listen(int socket, int backlog);

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

• socket — дескриптор сокета.

• backlog — желательный размер очереди подключений.

Параметр backlog в POSIX — это «подсказка» для реализации, поэтому нельзя гарантировать, что очередь будет именно того размера, который указан. Если размер меньше или равен 0, устанавливается некоторое минимальное значение по умолчанию, если же задать backlog выше максимально допустимого, он будет урезан до максимального.

Если количество одновременных подключений будет превышать этот параметр, следует ожидать, что клиент не сможет подключиться, однако реальное поведение не специфицировано и зависит от конкретной ОС.

Функция возвращает 0 при успехе и –1 в случае неудачи.

Параметр backlog

Если backlog превышен, в ОС Windows сервер отбросит соединение.

В Linux и некоторых других Unix-подобных ОС система просто отбросит SYN.

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

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

В Linux и в ОС Windows есть константа SOMAXCONN, которая определяет размер очереди.

В Windows можно задать большее значение, используя макрос SOMAXCONN_HINT, о чем см. в MSDN39.

В Linux этот лимит настраиваемый. По умолчанию — 4096 в ядрах с версии 5.4 и 128 в более старых версиях. Посмотреть и установить его можно через ProcFS в файле /proc/sys/net/core/somaxconn.

В Linux этот лимит определяет количество полностью установленных соединений в очереди, то есть сокеты, для которых может быть успешно завершен вызов функции accept(). В старых ядрах Linux он определял количество всех соединений, в том числе инициированных, не установленных полностью.

Кроме того, данный лимит для TCP может не работать, если используются TCP SYN Cookies, которые в Linux по умолчанию включены.

Рассмотрим, как создать базовый TCP-сервер, на примере функции обертки socket_wrapper::create_tcp_server():

Socket create_tcp_server(const std::string &port)

{

// Получить адресную информацию.

// Обычно для INADDR_ANY или "0.0.0.0".

// С таким адресом сервер будет прослушивать на всех адресах/интерфейсах.

const auto servinfo = get_serv_info(port);

// Создать серверный сокет.

Socket server_sock = {servinfo->ai_family, servinfo->ai_socktype,

servinfo->ai_protocol};

if (!server_sock)

{

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

}

// Установить опцию сокета SO_REUSEADDR. Про эту опцию читайте в главе 8.

set_reuse_addr(server_sock);

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

if (-1 == bind(server_sock, servinfo->ai_addr, servinfo->ai_addrlen))

{

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

}

// Включить прослушивание – сокет будет ожидать подключения.

// Вызов listen() просто изменит флаг на сокете.

// Он не блокирует выполнение, и работа продолжится.

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

{

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

}

return server_sock;

}

В Python данная функция представлена аналогичным методом класса socket.socket:

def listen(self, __backlog: int) -> None

Параметр __backlog является необязательным.

Использовать метод просто:

import socket

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

s.bind(('', 9080))

s.listen(10)

Функция accept()

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

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

Если запросов на соединение нет, дальнейшее выполнение потока блокируется до появления запроса.

Новый сокет будет новым объектом ядра и будет иметь новый дескриптор. Через этот сокет будет проходить общение с подключившимся клиентом.

В однопоточном сервере, который обслуживает только один клиент, серверный сокет, на котором была запущена функция listen(), после успешного вызова accept() может быть закрыт.

Функция объявлена в sys/socket.h или winsock.h для Windows.

Прототип функции:

int accept(int socket, struct sockaddr *restrict address,

socklen_t *restrict address_len);

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

• socket — прослушивающий сокет, на который пришел запрос на соединение.

• address — адрес структуры sockaddr, в которую вернутся данные адреса нового сокета. Может быть nullptr, если адрес подключившегося клиента неинтересен.

• address_len — длина структуры: sizeof(address) или 0, если адрес не был передан.

Внимание! В параметре address_len функция accept() возвращает значение. Если предоставленный буфер для адреса слишком мал, будет возвращено большее значение, чем было передано при вызове. Адрес в этом случае будет усечен.

Кроме того, в Linux существует функция accept4(), которая должна стать частью POSIX, но сейчас доступна как GNU-расширение в Linux:

// Чтобы функция стала доступна, этот макрос надо определить

// до включения файлов заголовков.

#define _GNU_SOURCE

#include <sys/socket.h>

int accept4(int sockfd, sockaddr *restrict addr,

socklen_t *restrict addrlen, int flags);

Отличие этой функции от функции accept() — в параметре flags, который может принимать следующие значения или побитовую комбинацию этих значений:

• 0 — функция полностью аналогична функции accept().

• SOCK_NONBLOCK — открыть сокет в неблокирующем режиме. Использование этого флага избавляет от дополнительных вызовов функций для установки неблокирующего режима. Подробнее о них — в главах 8 и 10 и в книге 2.

• SOCK_CLOEXEC — установить флаг FD_CLOEXEC для нового файлового дескриптора. Требуется, чтобы предотвратить утечку дескрипторов в многопроцессных приложениях. Об этом подробнее будет рассказано в главе 24.

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

Socket accept_client(socket_wrapper::Socket &server_sock)

{

// Почему здесь sockaddr_storage, рассказано далее – в разделе об IPv6.

sockaddr_storage client_addr;

socklen_t client_addr_length = sizeof(client_addr);

// Принять клиентское подключение.

Socket client_sock(accept(server_sock,

reinterpret_cast<sockaddr *>(&client_addr),

&client_addr_length));

if (!client_sock)

{

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

}

assert(sizeof(sockaddr_in) == client_addr_length);

std::array<char, INET_ADDRSTRLEN> addr;

// Напечатать IP-адрес клиента.

std::cout << "Client from "

<< inet_ntop(AF_INET,

&(reinterpret_cast<const sockaddr_in *const>(

&client_addr)->sin_addr),

&addr[0], addr.size())

<< "..." << std::endl;

// Вернуть клиентский сокет.

return client_sock;

}

Вызывается эта функция в простейшем случае обычно так:

// Рабочий цикл сервера.

while (true)

{

// Функции передается сокет, на котором сервер выполняет прослушивание.

// Вызов accept() блокирует выполнение до момента подключения клиента.

auto client_sock = accept_client(listen_sock);

// Тут выполняется работа с новым дескриптором, например, в новом потоке.

}

В Python метод socket.accept() возвращает новый объект socket.socket и адрес подключившегося клиента:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

# Ожидать подключения на любом адресе на порту 5000.

s.bind(('', 5000))

s.listen(socket.SOMAXCONN)

conn, addr = s.accept()

# Чтобы автоматически закрыть новый сокет,

# когда работа с ним прекратится, лучше использовать with.

with conn:

print('Connected by', addr)

Метода, подобного функции accept4(), в Python нет, и необходимые флаги можно задать при создании объекта сокета.

Стоит заметить, что если между вызовами на сервере listen() и accept() клиент успел сделать вызов connect() и пакет дошел до сервера, accept() сразу вернет новый сокет.

Реализация socketpair() в Python

На возможности мгновенного возврата из accept(), если незадолго до ее вызова было выполнено соединение, основана TCP/IP-реализация функции socketpair() в стандартной библиотеке Python.

Эта реализация используется, если рассмотренная в главе 3 функция socketpair() на данной платформе недоступна, к примеру, не поддерживается семейство PF_UNIX.

Рассмотрим ее. Сначала в функции создается прослушивающий сокет:

# Если отсутствует функция socketpair() в бинарном модуле.

# if not hasattr(_socket, "socketpair") ...

def socketpair(family=AF_INET, type=SOCK_STREAM, proto=0):

if family == AF_INET:

host = _LOCALHOST

elif family == AF_INET6:

host = _LOCALHOST_V6

else:

# Вероятно, если socketpair() нет в библиотеке, Unix-domain-сокеты

# в этой системе не поддерживаются.

# INET-сокеты накладывают дополнительные расходы и более

медленные.

# Зато функция будет работать.

raise ValueError("Only AF_INET and AF_INET6 socket address "

"families are supported")

if type != SOCK_STREAM:

raise ValueError("Only SOCK_STREAM socket type is supported")

if proto != 0:

raise ValueError("Only protocol zero is supported")

# Cоздать TCP-сокет.

lsock = socket(family, type, proto)

try:

# Сокет привязан к адресу localhost и будет слушать только интерфейс

# локальной петли: извне к нему подключиться нельзя.

# Нулевой порт означает случайный выбор порта для прослушивания.

lsock.bind((host, 0))

# Первый сокет – TCP-сервер, который ожидает подключения.

lsock.listen()

# Узнать, на каком порту сервер ожидает подключений.

# Для IPv6 игнорировать последние два параметра:

# flow_info и scope_id

addr, port = lsock.getsockname()[:2]

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

# Сокет клиента.

csock = socket(family, type, proto)

try:

# Если connect() не сможет подключиться сразу, он сгенерирует

# исключение.

# Установка произведена для того, чтобы избежать возможных

# зависаний на разных платформах, так как лучше получить

# неработающий канал, чем зависание по непонятным причинам.

# Но скорее всего, connect() пройдет сразу.

csock.setblocking(False)

try:

# TCP-клиент подключается к серверу.

csock.connect((addr, port))

except (BlockingIOError, InterruptedError):

pass

# Этот вызов снова устанавливает блокирующий режим,

# который является нормальным поведением сокета.

csock.setblocking(True)

# Вызов accept() вернет новый сокет для подключившегося

# клиента. Причем сделает это сразу, так как подключение уже

# будет в очереди.

ssock, _ = lsock.accept()

except:

csock.close()

raise

finally:

# Первый сокет, на котором выполнялось прослушивание,

# больше не требуется.

lsock.close()

# Возврат пары сокетов.

return (ssock, csock)

__all__.append("socketpair")

Это работает следующим образом:

1. Сначала в функции создается TCP/IP-сервер, который ожидает подключения от единственного клиента с localhost.

2. Сервер вызывает listen() и после этого может принять новое подключение.

3. Клиент делает connect() в неблокирующем режиме.

4. Так как сервер уже слушает порт, ожидая подключения, оно состоится.

5. После этого сервер вызывает accept(), который также сразу проходит, так как в очереди уже есть запрос на подключение.

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

Такие функции, как accept() и подобные, используются только с ориентированными на соединение сокетами, такими как SOCK_STREAM или SOCK_SEQPACKET.

Функция socket.create_server()

Данная функция модуля socket в Python — удобная обертка, которая позволяет создать потоковый Internet-сокет, установить необходимые параметры и запустить прием входящих соединений:

def create_server(address, *, family=AF_INET, backlog=None, reuse_port=False,

dualstack_ipv6=False)

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

• address — адрес и порт, на которых будет работать сервер, 2-tuple.

• family — семейство адресов. Допускается AF_INET и AF_INET6.

• backlog — параметр backlog метода socket.listen().

• reuse_port — флаг установки опции SO_REUSEPORT, о которой будет рассказано в главе 8 этой книги и в книге 2.

• dualstack_ipv6 — сокет будет принимать как IPv4-, так и IPv6-подключения, если платформа это поддерживает.

Функция create_server() поможет сократить объем кода при создании на Python сокетов для сервера:

with socket.create_server(('', 5000)) as s:

# Цикл обработки соединений.

while True:

conn, addr = s.accept()

with conn:

print('Connected by', addr)

Получение информации о точках подключения

После установки соединения сокеты на его концах сохраняют информацию о конечных точках.

Конечная точка, или эндпоинт, — это информация об абоненте сокета, достаточная, чтобы полностью адресовать этого абонента.

В случае TCP/IP-сокетов это адрес и порт.

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

Получить эндпоинты можно, используя функции:

• getpeername() — вернет адрес удаленного абонента, подключение к которому было выполнено вызовом connect().

• getsockname() — вернет адрес локального сокета на стороне вызывающего.

Рассмотрим их прототипы:

#include <sys/socket.h>

int getpeername(int socket, sockaddr *restrict address,

socklen_t *restrict address_len);

int getsockname(int socket, sockaddr *restrict address,

socklen_t *restrict address_len);

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

• socket — дескриптор сокета.

• address — связанный адрес. Если размер адреса меньше буфера, адрес будет усечен.

• address_len — указатель на размер структуры в address. Значение переменной, на которую он указывает, будет перезаписано реальным размером адреса, даже если адрес был усечен.

Как всегда, функции вернут 0 в случае успеха и –1 в ином случае.

Рассмотрим фрагмент кода, в котором вызываются данные функции:

socket_wrapper::SocketWrapper sock_wrap;

socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};

...

sockaddr_in my_address{0};

sockaddr_in his_address{0};

socklen_t my_address_len(sizeof(my_address));

socklen_t his_address_len(sizeof(his_address));

// Получить локальный адрес.

if (getsockname(sock, reinterpret_cast<sockaddr*>(&my_address),

&my_address_len) != 0)

{

std::cerr

<< "getsockname: "

<< sock_wrap.get_last_error_string()

<< std::endl;

return EXIT_FAILURE;

}

// Получить адрес удаленного абонента.

if (getpeername(sock, reinterpret_cast<sockaddr*>(&his_address),

&his_address_len) != 0)

{

std::cerr

<< "getpeername: "

<< sock_wrap.get_last_error_string()

<< std::endl;

return EXIT_FAILURE;

}

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

std::string my_ip(INET_ADDRSTRLEN, 0);

inet_ntop(AF_INET, &my_address.sin_addr, &my_ip[0], my_ip.size());

std::string his_ip(INET_ADDRSTRLEN, 0);

inet_ntop(AF_INET, &his_address.sin_addr, &his_ip[0], his_ip.size());

std::string user_passed_ip(INET_ADDRSTRLEN, 0);

inet_ntop(AF_INET, &server_addr.sin_addr,

&user_passed_ip[0], user_passed_ip.size());

std::cout

<< "User passed address: "

<< user_passed_ip << " (" << host_name <<  "):" << argv[2] << "\n"

<< "My address: "

<< my_ip << ":" << ntohs(my_address.sin_port) << "\n"

<< "Another host address: "

<< his_ip << ":" << ntohs(his_address.sin_port)

<< std::endl;

Результат:

➭ build/bin/b01-ch05-socket-address google.com 443

User passed address: 216.58.211.14 (google.com):443

My address: 192.168.2.13:33928

Another host address: 216.58.211.14:443

В Python данные функции являются методами класса socket.socket:

def getsockname(self) -> address info

def getpeername(self) -> address info

Для AF_INET адрес будет возвращен как кортеж (tuple), содержащий адрес и порт:

import socket

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

sock.connect(('google.com', 443))

print(sock.getsockname())

print(sock.getpeername())

Результат:

('192.168.2.13', 59892)

('142.250.74.78', 443)

Данные функции вернут результат не для всех семейств протоколов. Посмотрим на AF_UNIX:

>>> import socket

>>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, 0)

>>> print(sock.getsockname())

>>> print(sock.getpeername())

Traceback (most recent call last):

File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

То есть если сокету не был назначен адрес, метод getsockname() вернет пустую строку, а getpeername() сгенерирует исключение. Это стоит учитывать при разработке.

Теперь привяжем адрес:

>>> sock.bind('local_file.ext')

>>> print(sock.getpeername())

Traceback (most recent call last):

File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

>>> print(sock.getsockname())

local_file.ext

>>> sock.listen()

>>> s, a = sock.accept()

>>> s.getpeername()

''

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

В UNIX-сокетах метод getpeername() для клиентского сокета, полученного методом accept(), также вернет пустую строку.

В TCP-сокетах это не так, и будет возвращен адрес удаленного абонента.

На клиенте:

>>> import socket

>>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, 0)

>>> print(sock.getsockname())

>>> print(sock.getpeername())

Traceback (most recent call last):

File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

>>> sock.connect('local_file.ext')

>>> print(sock.getpeername())

local_file.ext

>>> print(sock.getsockname())

Видно, что getsockname() возвращает пустую строку даже после успешного вызова connect(). Если вы внимательно читали этот раздел, вам должно быть понятно, чем обусловлено такое поведение.

Адрес на стороне «локальной точки подключения» устанавливает метод bind(). Он не был вызван, а в UNIX-сокетах адрес не генерируется автоматически, так как этого не требуется: сокету достаточно одного имени файла. Вызов же метода connect() выполняет подключение и устанавливает адрес удаленного абонента.

Обмен данными

При установленном соединении адреса конечных точек канала уже зафиксированы, передавать их функциям приема и передачи данных не требуется. Конечно, можно использовать функции sendto() и recvfrom(), которые мы рассмотрели в главе 3, но они будут игнорировать переданные им адреса, то есть работать аналогично функциям send() и recv().

Функции send() и recv()

Эти функции отправляют и принимают данные. Обычно они используются для сокетов, ориентированных на соединение. В случае других сокетов после вызова connect(), связывающего адрес, на клиентской стороне они будут работать аналогично sendto() и recvfrom().

Функции send() и recv() могут быть как блокирующими, так и неблокирующими. В Linux это поведение включается через ioctl FIONBIO либо передачей соответствующего флага при вызове.

Функции объявлены в sys/socket.h или winsock2.h.

Прототипы функций:

ssize_t send(int socket, const void *buffer, size_t length, int flags);

ssize_t recv(int socket, void *buffer, size_t length, int flags);

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

• socket — дескриптор сокета;

• buffer — указатель на данные;

• length — размер буфера;

• flags — флаги. Повторяют флаги для функций sendto() и recvfrom().

Пример использования:

// Потоковый сокет и протокол TCP.

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

if (sock_fd < 0)

{

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

"Error creating socket!");

}

std::array<char, 1024> buffer;

auto addrs = socket_wrapper::get_client_info("87.250.250.242", 80);

if (0 == connect(sock_fd, addrs->ai_addr, addrs->ai_addrlen))

{

std::string request = { "GET / HTTP/1.1\nHost: ya.ru\n\n" };

// Отправка данных.

if (send(sock_fd, request.c_str(), request.length(), 0) < 0)

{

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

}

// Прием данных в буфер, дополнительные флаги не установлены.

auto recv_bytes = recv(sock_fd, buffer.data(), buffer.size(), 0);

if (recv_bytes <= 0)

{

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

}

buffer[recv_bytes] = '\0';

cout << buffer << std::endl;

}

Вместо данных функций можно использовать функции read() и write(), но такой код хуже переносим. Он не будет работать в ОС Windows, где дескрипторы сокетов не являются файловыми дескрипторами.

Функция send() вернет:

• –1 в случае ошибки;

• количество отправленных байтов в случае успеха, причем не обязательно равное объему данных, которые вы хотели отправить.

Если объем отправленных данных меньше требуемого, необходимо запустить send() еще раз. В блокирующем режиме стандарт POSIX гарантирует, что send() отправит все данные. То же справедливо для write(), sendto() и подобных функций.

Внимание! Функция send() в общем случае не отправит все требуемые данные! Отправлено будет такое количество байтов, какое функция вернула, если завершилась без ошибки. Поэтому для отправки большого объема данных желательно многократно повторять ее в цикле.

Функция recv() вернет:

• количество прочитанных байтов в случае успеха;

• –1 в случае ошибки;

• 0, если другая сторона завершила соединение.

Для функции recv() и потоковых сокетов нужно отметить флаг MSG_WAITALL, которым можно пользоваться, если производится обмен записями фиксированной длины, указанной при вызове recv().

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

Прерывание вызовов по сигналу

Существует особенность в использовании сокетных функций на Unix-подобных ОС: их выполнение может быть прервано сигналом, и в грамотно написанном приложении это надо учитывать.

Вот неполный список функций, которые могут быть прерваны:

• Установление соединения: connect() и accept().

• Чтение и запись данных: read(), readv(), write(), writev().

• Отправка и прием данных: send(), sendto(), sendmsg(), recv(), recvfrom(), recvmsg().

• Мультиплексоры ввода-вывода — функции, ожидающие событий на дескрипторе: select(), pselect(), poll(), ppoll(), epoll_wait(), epoll_pwait().

Другие функции не касаются сокетного интерфейса, их список см. в man 7 signal.

Посмотрим, как реализован метод ProxyServer::read_line() из прокси-сервера, который будет приведен в главе 21. Данный метод посимвольно читает строку из буфера сокета, используя recv():

std::string ProxyServer::read_line(socket_wrapper::Socket &s) const

{

// Тип ssize_t – это условный "size_t со знаком".

// Вероятно, ssize_t будет иметь меньший размер, чем size_t.

// Но их тип не определяется стандартом и зависит от реализации.

ssize_t read_bytes;

std::string result;

char ch;

for (;;)

{

// Прием данных (чтение из сокета).

read_bytes = ::recv(s, &ch, 1, 0);

if (-1 == read_bytes)

{

// Обработка прерывания, например, по сигналу,

// read() перезапускается.

if (EINTR == errno) continue;

// Другая ошибка.

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

std::system_category(),

sock_wrap_.get_last_error_string());

}

// Соединение завершено.

else if (0 == read_bytes) { break; }

else

{

// Добавить символ.

result += ch;

// Окончание прочитанной строки.

if ('\n' == ch) break;

}

}

return result;

}

Видим, что если errno равна EINTR, — это не ошибка, просто recv() был прерван сигналом, и нужно его перезапустить. Это справедливо для всех функций из списка выше, а также некоторых других, которые могут работать длительное время.

Не в каждом прикладном коде производится такая обработка, и ее отсутствие может привести к ошибкам.

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

Тем не менее вызов может быть прерван, если на сокет был установлен тайм-аут, например, через ioctl SO_RCVTIMEO. Кроме того, такие вызовы, как read(), readv(), write(), writev(), ioctl(), могут быть прерваны при работе на «медленных устройствах», то есть в случае использования диска или сети.

Кроме проверки ошибки и повторения вызова, существует другой вариант решения проблемы — регистрация обработчика с флагом SA_RESTART40 через sigaction().

Внимание! В Unix-подобных системах, если приложение завершится, отправив серверу RST (в случае TCP) в то время, как сервер записывает в сокет, сервер получит сигнал SIGPIPE. Если этот сигнал не обрабатывать и не игнорировать, приложение сервера будет завершено! Обработке сигналов нужно уделять достаточное внимание. Например, данный сигнал можно «обработать», используя флаг MSG_NOSIGNAL при вызове send() и recv().

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

Отправка и прием данных в Python

В Python вышеприведенные функции существуют как методы класса socket.socket:

from collections.abc import Buffer

ReadOnlyBuffer = bytes

# В качестве Buffer часто можно увидеть такие типы, как:

# bytearray, memoryview, array.array, ...

WriteableBuffer = collections.abc.Buffer

ReadableBuffer = ReadOnlyBuffer | WriteableBuffer

def send(self, data: ReadableBuffer, flags: int | None = None) -> int

def sendall(self, data, flags: int | None = None) -> None

def recv(self, bufsize: int, flags: int | None = None) -> bytes

def recv_into(self, buffer: WriteableBuffer, nbytes: int,

flags: int | None = None) -> int

В первом случае параметры соответствуют параметрам send() и recv().

Метод recv_into() используется для того, чтобы принять данные в предварительно выделенный буфер размером nbytes.

Сигнал EINTR обрабатывается, как описано в PEP 475 «Retry system calls failing with EINTR»: до версии Python 3.5 разработчику приходилось самостоятельно обрабатывать исключение InterruptedError, но последние версии Python будут сами повторять вызов, если обработчик сигнала не сгенерирует исключение.

Метод socket.sendall() запускает отправку в цикле и нужен для того, чтобы не писать код вида:

def send_request(s: socket.socket, request: str):

req_length: int = len(request)

req_pos: int = 0

while True:

# Отправка данных.

bytes_count = sock.send(request[req_pos:])

if bytes_count == 0:

break

req_pos += bytes_count

if req_pos >= req_length:

break

Собственно, метод делает то же самое, что и код выше, но реализован на C и, кроме того, правильно работает с тайм-аутом, если тот установлен для объекта socket.socket.

Внимание! В Python вместо цикла отправки используйте метод sendall()!

Правильный код с обработкой сигнала до версии Python 3.5:

import socket

with socket.create_server(('', 5000)) as s:

while True:

conn, addr = s.accept()

with conn:

print('Connected by', addr)

buffer = 'test string'.encode()

buffer_len = len(buffer)

while buffer_len:

try:

buffer_len -= conn.send(buffer)

except InterruptedError:

continue

В новых версиях Python:

import socket

with socket.create_server(('', 5000)) as s:

while True:

conn, addr = s.accept()

with conn:

print('Connected by', addr)

buffer = 'test string'.encode()

buffer_len = len(buffer)

while buffer_len:

buffer_len -= conn.send(buffer)

Обработка других ошибок не показана, но очевидно, что код стал проще.

Завершение соединения

Установленное соединение обычно требует корректного завершения. Его процедура зависит от протокола и часто бывает достаточно сложной. Поэтому для этой цели существует несколько вызовов API.

Функция shutdown()

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

Функция объявлена в sys/socket.h или winsock2.h, константы объявлены там же.

Прототип функции:

int shutdown(int sockfd, int how);

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

• sockfd — дескриптор сокета для завершения соединения.

• how — тип завершения:

• SHUT_RD или SD_RECIEVE в ОС Windows — запретить прием данных;

• SHUT_WR или SD_SEND в ОС Windows — запретить отправку данных;

• SHUT_RDWR или SD_BOTH в ОС Windows — запретить прием и отправку данных.

Внимание! Функция shutdown(), как и закрытие сокета, может завершиться не сразу, если для сокета не указана опция SO_LINGER, равная 0. По умолчанию опция установлена в ненулевое значение. Это сделано для того, чтобы избежать потери отправленных данных.

Работа функции зависит от протокола. Например, для TCP и SCTP можно закрыть один конец соединения, что позволит только отправлять или только принимать данные.

Вызов shutdown(SHUT_RD) не отправляет ничего, просто «устанавливает флаг закрытия». После этого при попытке принять данные из сокета будет возвращаться 0 байт и любые отправленные удаленным абонентом данные не будут приняты.

Вызов shutdown(SHUT_WR) в случае TCP отправит FIN-пакет. Это уведомит принимающую сторону о том, что отправка данных закончена.

При этом абонент, который больше не планирует отправлять данные, может ожидать данные от другого абонента. Например, оставшийся блок файла или конец потока, а также OOB-данные (Out Of Band, внеполосные данные, подробнее см. глоссарий).

Существуют причины для явного использования shutdown():

• В некоторых случаях асинхронной работы, о которой мы поговорим в книге 2, есть смысл управлять корректным завершением соединения вручную. При использовании select() для получения изменений на группе сокетов будет получено оповещение FD_CLOSE41, и для этого сокета можно будет вычитать недостающие данные, например, в отдельном потоке.

• Функция shutdown() влияет на все копии сокета, тогда как функция close() влияет только на дескриптор файла в одном процессе, то есть вызов shutdown() точно завершит соединение.

• Вызов shutdown(SHUT_RDWR) для завершения обмена в обоих направлениях без закрытия сокета может быть полезен, например, если через вызов fdopen() был создан поток типа FILE, который используется для обмена данными поверх сокета. Если сокет будет закрыт через close(), следующему открытому файлу может быть назначен тот же дескриптор. В результате последующее использование открытого ранее через fdopen() потока FILE приведет к записи в неправильном месте, что может повлечь непредсказуемые последствия.

После завершения вызова необходимо вызвать close() для окончательного закрытия дескриптора.

Почему shutdown() встречается редко

Почему в таком случае редко встречается вызов shutdown()?

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

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

Когда эти данные вычитаны, соединение можно закрывать через close() и больше ничего не вычитывать: никакие данные «вдогонку» не придут.

В случае же HTTP Persistent Connection перед закрытием придет заголовок Connection: close — после этого сервер или клиент не будут отправлять данные.

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

Рис. 5.3. Правильный алгоритм закрытия сокета

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

Внимание! Фактически соединение закрывается вызовом функции shutdown(). Закрытие сокета через close() или closesocket() неявно вызывает shutdown(). Однако функция close() не будет вызывать shutdown() и закрытие соединения, если остаются дескрипторы, указывающие на данное соединение. Это может случиться, когда дескриптор сокета был продублирован, например, через вызов, подобный dup().

Время ожидания завершения соединения

Стандартное TCP-соединение завершается 4-этапной финализацией, иначе называемой graceful shutdown («изящное завершение», то есть правильное, согласно штатным процедурам алгоритма, без потери данных):

1. Когда абонент больше не имеет данных на передачу, он выполняет отправку пакета FIN или последовательность FIN, ACK, сигнализируя о том, что все пакеты приняты и соединение будет завершено.

2. Другой абонент возвращает ACK для FIN.

3. Когда другой абонент также завершил передачу данных, он отправляет еще один пакет FIN или также последовательность FIN, ACK.

4. Первоначальный участник возвращает ACK и завершает передачу.

Четырехэтапная финализация показана на рис. 5.4.

Рис. 5.4. Закрытие TCP-соединения

Ожидание ACK для своего пакета FIN или пакета FIN в ответ на свой переводит абонента, инициировавшего закрытие соединения, в состояние TIME-WAIT, в котором он будет находиться до истечения таймера.

В Linux значение таймера по умолчанию равно 60 секунд:

➭ cat /proc/sys/net/ipv4/tcp_fin_timeout

60

То есть вызов shutdown() или вызов close(), если он закрывает последний дескриптор и завершает соединение, может ожидать до минуты.

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

1. Абонент отправляет пакет RST и разрывает соединение.

2. Другой абонент получает RST, а затем также завершает соединение.

Это быстрый вариант закрытия, но он приводит к потере данных. Соединение будет закрываться данным способом, если опция сокета SO_LINGER равна 0.

В Python метод shutdown() класса socket.socket полностью соответствует C API:

def shutdown(self, __how: int) -> None

В качестве значения параметра __how используются:

• socket.SHUT_RD;

• socket.SHUT_WR;

• socket.SHUT_RDWR.

Реализация TCP-клиента по типу приложения Telnet

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

Примерно так работает приложение Telnet по одноименному протоколу, как показано на рис. 5.5.

Рис. 5.5. Протокол Telnet

Мы не будем полностью реализовывать Telnet-протокол, так как это сейчас не требуется, а реализуем только запрос произвольных данных и получение ответа.

Сначала посмотрим, как реализовать приложение на C++. Определим константу, задающую размер буфера, и добавим макроопределение для функции ioctl():

#ifdef _WIN32

#   define ioctl ioctlsocket

#else

extern "C"

{

#   include <netinet/tcp.h>

#   include <sys/ioctl.h>

#   include <fcntl.h>

}

#endif

using std::chrono_literals::operator""ms;

constexpr auto MAX_RECV_BUFFER_SIZE = 256;

Реализуем функцию для отправки запроса:

bool send_request(int sock, const std::string &request)

{

// Функция отправки данных "в общем виде".

size_t req_pos = 0;

const auto req_length = request.length();

while (req_pos < req_length)

{

if (ssize_t bytes_count = send(sock, request.c_str() + req_pos,

req_length – req_pos, 0);

bytes_count < 0)

{

// Здесь это лишнее – мы не обрабатываем сигналы.

if (EINTR == errno) continue;

return false;

}

else

{

// Сместить указатель на свободное место в буфере.

req_pos += bytes_count;

}

}

return true;

}

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

Функция чтения, в которой обработка прерывания чтения реализована так же, как обработка прерывания записи в функции передачи:

bool recv_request(const socket_wrapper::Socket &sock)

{

std::array<char, MAX_RECV_BUFFER_SIZE> buffer;

while (true)

{

// Прочитать данные. Если данных нет, будет возвращен -1, а errno

// установлена в 0, то есть отсутствие ошибки.

// Это неблокирующий режим.

const auto recv_bytes = recv(sock, buffer.data(), buffer.size() – 1, 0);

std::cout << recv_bytes << " was received..." << std::endl;

if (recv_bytes > 0)

{

// Создать из буфера строку и вывести на консоль.

buffer[recv_bytes] = '\0';

std::cout << "------------\n"

<< std::string(buffer.begin(), std::next(buffer.begin(),

recv_bytes))

<< std::endl;

continue;

}

else if (-1 == recv_bytes)

{

// Для Windows корректнее будет проверять WSAGetLastError()

// вместо errno.

if (EINTR == errno) continue;

if (0 == errno) break;

// -1 тут не ошибка. Если данных нет, errno будет содержать

// код EAGAIN или Resource temporarily unavailable.

// Но здесь это нормально.

if (EAGAIN == errno) break;

return false;

}

break;

}

return true;

}

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

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

{

...

const socket_wrapper::SocketWrapper sock_wrap;

// TCP-сокет.

const socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};

if (!sock)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

assert(argv[1]);

const std::string host_name = { argv[1] };

assert(argv[2]);

auto addrs = socket_wrapper::get_client_info(host_name, argv[2],

SOCK_STREAM);

// Подключиться к серверу.

if (connect(sock, addrs->ai_addr, addrs->ai_addrlen) != 0)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

std::cout << "Connected to \"" << host_name << "\"..." << std::endl;

Установим опции сокета:

const int flag = 1;

// Перевести сокет в неблокирующий режим.

// Закомментированный вариант не работает для Windows.

// Вариант с ioctl()/ioctlsocket() – кросс-платформенный.

// #if !defined(_WIN32)

//    if (fcntl(sock, F_SETFL, fcntl(sock, F_GETFL) | O_NONBLOCK) < 0)

// #else

if (ioctl(sock, FIONBIO, const_cast<int *>(&flag)) < 0)

// #endif

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

// Выключить алгоритм Нейгла.

if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY,

reinterpret_cast<const char *>(&flag), sizeof(flag)) < 0)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

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

В рабочем цикле мы сначала читаем строку из консоли, а затем отправляем ее в сокет:

std::cout << "Waiting for the user input..." << std::endl;

std::string request;

while (true)

{

std::cout << "> " << std::flush;

// Прочитать строку из консоли.

if (!std::getline(std::cin, request)) break;

std::cout

<< "Sending request: \"" << request << "\"..."

<< std::endl;

// "HTTP-завершение" строки.

request += "\r\n";

// Отправить строку.

if (!send_request(sock, request))

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

std::cout

<< "Request was sent, reading response..."

<< std::endl;

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

std::this_thread::sleep_for(2ms);

if (!recv_request(sock))

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

}

}

Функция чтения также выводит полученные данные на экран. В случае ошибки будет выведено соответствующее сообщение, но EINTR и отсутствие данных ошибкой не являются.

Пример работы клиента:

➭ build/bin/b01-ch05-tcp-client yandex.ru 80

Connected to "yandex.ru"...

Waiting for the user input...

> GET / HTTP/1.1

Sending request: "GET / HTTP/1.1"...

Request was sent, reading response...

-1 was received...

> Host: yandex.ru

Sending request: "Host: yandex.ru"...

Request was sent, reading response...

-1 was received...

>

Sending request: ""...

Request was sent, reading response...

-1 was received...

>

Sending request: ""...

Request was sent, reading response...

255 was received...

------------

HTTP/1.1 302 Moved temporarily

Accept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Platform, Sec-CH-UA-Full-Version, Viewport-Widt

255 was received...

------------

h, DPR, Device-Memory, RTT, Downlink, ECT

Cache-Control: max-age=1209600,private

...

set-cookie: _yasc=RuZeMtYqbhQuwoQYQkdKzjV+zn1AEsNwUFwadVwXIb0PaQJ7TQXdXsPJ+

bl0MA==; domain=.yandex.ru; path=/; e

53 was received...

------------

xpires=Sun, 29 May 2033 12:04:55 GMT; secure

0

-1 was received...

Внимание! Еще раз подчеркнем, что данные приходят неравномерными порциями, так как TCP обеспечивает поток данных.

В Python имеется стандартная библиотека telnetlib, которая реализует полноценный Telnet-протокол. Но с версии 3.11 библиотека является устаревшей согласно PEP 594 «Removing dead batteries from the standard library».

Вместо нее рекомендуется использовать библиотеку telnetlib3, которую можно установить через PyPI.

Поэтому рассматривать библиотеку telnetlib мы не будем, а реализуем тот же вариант клиента, что и на C++.

Прием данных выполняется функцией recv_request(), которая использует предварительно выделенный буфер:

import argparse

from array import array

import socket

MAX_RECV_BUFFER_SIZE: int = 256

def recv_request(sock: socket.socket) -> bool:

"""Принять данные из сокета"""

buffer = array('b', [0] * MAX_RECV_BUFFER_SIZE)

while True:

try:

# Принять ответ.

recv_bytes = sock.recv_into(buffer, len(buffer) – 1, 0)

print(f'{recv_bytes} was received...')

except BlockingIOError:

# Это не ошибка.

print('BlockingIOError was caught...')

return True

# Отсутствие данных не ошибка.

if recv_bytes < 0:

return False

if recv_bytes == 0:

return True

buffer[recv_bytes] = 0

print('------------')

print(buffer.tobytes())

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

if '__main__' == __name__:

parser = argparse.ArgumentParser(description='Telnet example.')

parser.add_argument('host', type=str, help='host name')

parser.add_argument('port', type=int, default=23, help='telnet port')

args = parser.parse_args()

# Новый сокет.

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

socket.IPPROTO_TCP)

# Подключить к заданному адресу.

sock.connect((args.host, args.port))

print(f'Connected to "{args.host}"...')

# Перевести в неблокирующий режим.

sock.setblocking(False)

# Отключить алгоритм Нейгла.

sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)

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

while True:

user_request = input('> ')

print(f'Sending request: "{user_request}"...')

user_request += '\n'

# В методе sendall() уже реализована отправка в цикле.

sock.sendall(user_request.encode())

print('Request was sent, reading response...')

if not recv_request(sock):

break

Вызов sock.recv_into() можно заменить вызовом sock.recv():

try:

recv_bytes = sock.recv(MAX_RECV_BUFFER_SIZE – 1, 0)

print(f'{recv_bytes} was received...')

except BlockingIOError:

print('BlockingIOError was caught...')

return True

else:

if recv_bytes < 0:

return False

# Отсутствие данных не ошибка.

elif recv_bytes == 0:

return True

print('------------')

print(recv_bytes)

Этот код работает точно так же, как код на C++, но вместо результата –1, если recv() требует блокировки, в Python будет сгенерировано исключение BlockingIOError, которое необходимо обработать.

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

➭ src/book01/ch05/python/tcp-client.py yandex.ru 80

Connected to "yandex.ru"...

> GET / HTTP/1.1

Sending request: "GET / HTTP/1.1"...

Request was sent, reading response...

BlockingIOError was caught...

> Host: yandex.ru

Sending request: "Host: yandex.ru"...

Request was sent, reading response...

BlockingIOError was caught...

>

Sending request: ""...

Request was sent, reading response...

BlockingIOError was caught...

>

Sending request: ""...

Request was sent, reading response...

255 was received...

------------

b'HTTP/1.1 302 Moved temporarily\r\nAccept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Platform, Sec-CH-UA-Full-Version, Viewport-Widt\x00'

255 was received...

------------

b'h, DPR, Device-Memory, RTT, Downlink

...

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

Резюме

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

Одним из важных различий между TCP и UDP является передача данных как потока. В случае UDP, если последовательно отправить серверу слова «hello» и «exit», будет получена дейтаграмма, содержащая «hello», и дейтаграмма, содержащая «exit».

В случае TCP может быть получена любая комбинация: «hello», «exit» или «he», «llo», «exi», «t». Поэтому требуется протокол верхнего уровня, например HTTP, управляющий выделением осмысленных данных из потока.

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

Чтобы установить соединение, клиент должен вызвать функцию connect() на сокете и начать обмен данными, используя функции send() и recv().

Алгоритм работы сервера, ориентированного на соединение, несколько сложнее. Он привязывает сокет к прослушиваемым порту и адресу вызовом функции bind(). Затем выполняет подготовку сокета к ожиданию соединений при помощи вызова listen(). И, многократно повторяя вызов accept(), принимает новые клиенты.

Для обмена данными с клиентом используются те же самые функции send() и recv() на сокете, который вернула функция accept(). Сокет, на котором был вызван listen(), будет ожидать новые соединения.

После установки соединения сокеты на его концах сохраняют информацию о конечных точках. Для получения адреса конечных точек сокета используются функции getpeername() и getsockname().

В Unix-подобных ОС функции обмена данными и еще некоторые могут быть прерваны сигналом, возвращая EINTR, который требуется обрабатывать, проверяя код ошибки. Но в более высокоуровневых языках, например в Python новых версий, эта проблема обрабатывается библиотекой и скрыта от разработчика.

Соединение должно быть корректно закрыто. Функция close(), используемая для этого, если дескрипторов, ссылающихся на соединение, не осталось, вызывает функцию shutdown(). Вызов этой функции разрывает соединение.

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

Клиентский API мы рассмотрели на примере неполной реализации Telnet-протокола. Серверный API рассмотрим далее.

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

1. Чем с точки зрения разработчика приложений отличаются протоколы TCP и UDP?

2. В чем отличие работы с сокетами для протоколов TCP и UDP?

3. Каким образом TCP гарантирует доставку данных в нужном порядке?

4. В чем отличие алгоритма сервера, ориентированного на соединения, от «сервера» протокола, в котором соединение не предполагается?

5. Почему для транспортных протоколов в приложениях требуется протокол верхнего уровня? Бывают ли ситуации, когда прикладной протокол не требуется?

6. Что такое виртуальный канал, или соединение, в случае TCP и схожих с ним протоколов?

7. К чему приведет вызов функции connect() при использовании UDP? Можно ли сказать, что в результате «будет установлено соединение»?

8. Можно ли на сокете клиента в TCP-сокетах использовать функцию bind()? Если да, то для чего?

9. Безопасно ли в Python использовать функции socket.inet_pton() и socket.getaddrinfo() вне блока обработки исключений?

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

11. Для чего нужен параметр backlog функции listen()? Как определить желательное значение параметра?

12. Для чего нужно использовать функцию accept() и можно ли использовать тот же сокет, на котором была вызвана функция listen()? Почему?

13. Как понять, что размер буфера адреса подключившегося клиента в функции accept() недостаточен?

14. Всегда ли в Python можно использовать функцию socket.create_server() для создания нового серверного сокета? Почему?

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

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

17. Какой метод класса socket.socket обычно лучше использовать для отправки данных в Python?

18. Что такое конечная точка и какими свойствами она обладает?

19. Что нужно учитывать при реализации вышележащих протоколов, когда в качестве транспортного протокола используется TCP?

20. Какие проблемы могут возникнуть при обмене данными в Unix-подобных ОС? Как их обрабатывать?

21. Чем закрытие сокета через вызов shutdown() отличается от закрытия через close() или closesocket()?

22. Почему как в большинстве примеров кода, так и в коде реальных приложений функция shutdown() не вызывается?

23. Сколько времени может занять в TCP завершение соединения и есть ли возможность уменьшить это время?

24. Переделайте клиент, отправляющий команду, из задания 19 главы 4 на TCP. Если вы не реализовывали UDP-клиент, реализуйте TCP-клиент сейчас.

25. Реализуйте отдачу сервером файла, путь к которому задает клиент.

39 https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-listen

40 https://stackoverflow.com/questions/13357019/how-to-know-if-a-linux-system-call-is-restartable-or-not/13357338

41 https://learn.microsoft.com/en-us/windows/win32/winsock/graceful-shutdown-linger-options-and-socket-closure-2?redirectedfrom=MSDN

Глава 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».

Глава 7. Сокет в ядре Linux

Линуксоиды делают то, что делают, потому что ненавидят Microsoft. Мы делаем то, что делаем, потому что любим Unix.

Тео де Раад, «Is Linux For Losers?», 2007

Введение

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

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

Рассмотрев структуры ядра, можно представить в общих чертах, как устроены сокеты на низком уровне, что помогает глубже понять и качественно освоить материал — в данном случае только для Linux. Но если вы создаете продукт, работающий по Сети, вы, скорее всего, будете использовать Linux, ведь эта ОС — одна из наиболее распространенных на серверах. К тому же общие принципы справедливы и для некоторых других платформ.

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

Для рассмотрения структур ядра можно воспользоваться сайтом elixir.bootlin.com, который содержит исходный код разных версий ядер Linux и позволяет рассматривать все структуры в браузере, удобно перемещаясь по ссылкам.

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

Код ядра также можно посмотреть на Github.

Общие структуры для сокетов

Кратко рассмотрим, что представляет собой реальный сокет в ядре ОС. Возьмем для примера Linux 6.9.

Основные структуры определены в include/linux/net.h и include/linux/sock.h.

Структура socket

Структура является представлением сокета в ядре, BSD-сокетом «общего вида»:

// Основная структура BSD-сокета.

struct socket

{

// Состояние сокета: SS_CONNECTED, etc.

socket_state state;

// Тип сокета: SOCK_STREAM, etc.

short type;

// Флаги сокета: SOCK_NOSPACE, etc.

unsigned long flags;

// Указатель на файл, используется для сборщика мусора.

struct file *file;

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

// Структура показана ниже.

struct sock *sk;

// Операции, специфичные для протокола.

const struct proto_ops *ops;

// Очередь ожидания.

struct socket_wq wq;

};

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

Тип сокета задается явно, при вызове функции socket(). Флаги устанавливает ядро. Например, флаг SOCK_NOSPACE будет установлен, если в буфере сокета закончилось место. Когда этот флаг установлен, запись в сокет и его асинхронный опрос будут ждать освобождения буфера и сброса флага. То есть в блокирующем режиме запись будет либо ожидать, либо вернет ошибку, говорящую о том, что невозможно отправить данные без блокировки записи.

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

В реальности код работает несколько сложнее, но принцип сохраняется, и назначение поля флагов должно быть понятно.

Прочие атрибуты:

• sk, структура sock — внутреннее представление сокета, которое не зависит от протокола. Используется только внутри ядра, а детали могут изменяться в его разных версиях.

• ops содержит указатели на функции, которые выполняются над сокетом.

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

Структура sock

Рассмотрим внутреннюю структуру. В ней также есть вложенные поля-структуры. Для упрощения доступа к ним определены некоторые макросы.

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

struct sock

{

// Структура, которая содержит адреса, порт и параметры семейства,

// некоторые общие параметры, такие как состояние.

struct sock_common __sk_common;

// Общепринятая практика в C: sock.sk_node – то же самое,

// что и sock.__sk_common.skc_node, но более кратко.

#define sk_node                 __sk_common.skc_node

#define sk_nulls_node           __sk_common.skc_nulls_node

#define sk_refcnt               __sk_common.skc_refcnt

#define sk_tx_queue_mapping     __sk_common.skc_tx_queue_mapping

#ifdef CONFIG_SOCK_RX_QUEUE_MAPPING

#define sk_rx_queue_mapping     __sk_common.skc_rx_queue_mapping

#endif

#define sk_dontcopy_begin       __sk_common.skc_dontcopy_begin

#define sk_dontcopy_end         __sk_common.skc_dontcopy_end

...

#define sk_prot                 __sk_common.skc_prot

#define sk_net                  __sk_common.skc_net

...

// Счетчик отброшенных пакетов raw/udp.

atomic_t sk_drops;

// Смещение, по которому возможно прочитать данные в буфере сокета.

// Текущее значение peek_offset используется для работы SO_PEEK_OFF.

__s32 sk_peek_off;

// Очередь пакетов ошибок ICMP.

struct sk_buff_head sk_error_queue;

// Очередь входящих пакетов.

struct sk_buff_head sk_receive_queue;

Следующий атрибут нужен для ускорения доступа. Это очередь невыполненной работы:

// Очередь невыполненной работы используется с удерживаемой

// спин-блокировкой для каждого сокета и требует доступа с малой

// задержкой.

// rmem_alloc находится в этой структуре,

// чтобы заполнить "дыру" на 64-битных архитектурах.

struct

{

atomic_t rmem_alloc;

int len;

struct sk_buff *head;

struct sk_buff *tail;

} sk_backlog;

#define sk_rmem_alloc sk_backlog.rmem_alloc

В этой же структуре содержится поле индекса интерфейса, к которому привязан сокет, флаги, а также используемые для фильтрации трафика структуры:

...

/* Поля для раннего демультиплексирования */

// Получает входной маршрут, используемый демультиплексором.

struct dst_entry __rcu *sk_rx_dst;

// Индекс интерфейса ifindex для @sk_rx_dst

int sk_rx_dst_ifindex;

// Куки для @sk_rx_dst

u32 sk_rx_dst_cookie;

// Флаги SO_SNDBUF и SO_RCVBUF.

u8 sk_userlocks;

// Размер буфера приема в байтах.

int sk_rcvbuf;

// Инструкции фильтрации для сокета.

struct sk_filter __rcu *sk_filter;

union

{

// Очередь ожидания сокета и головной элемент списка, используемого

// асинхронными операциями.

struct socket_wq __rcu *sk_wq;

/* private: */

struct socket_wq *sk_wq_raw;

/* public: */

};

// Обратный вызов, вызываемый, когда есть данные для обработки.

void (*sk_data_ready)(struct sock *sk);

// Значение опции SO_RCVTIMEO.

int sk_rcvtimeo;

// Значение опции SO_RCVLOWAT.

int sk_rcvlowat;

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

// Код последней ошибки.

int sk_err;

// Обратная ссылка на сокет.

struct socket *sk_socket;

// Ассоциированная с памятью сокета контрольная группа.

struct mem_cgroup *sk_memcg;

// Переменная для синхронизации.

socket_lock_t sk_lock;

// Пространство, зарезервированное для сокета.

u32 sk_reserved_mem;

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

int sk_forward_alloc;

// Флаги SO_TIMESTAMPING.

u16 sk_tsflags;

// Ожидающие запросы на запись в потоковый сокет.

int sk_write_pending;

// Счетчик очереди пакетов, которые пришли вне очереди.

atomic_t sk_omem_alloc;

// Размер буфера на отправку в байтах.

int sk_sndbuf;

// Постоянный размер очереди передачи.

int sk_wmem_queued;

// Подтвержденное число байтов в очереди передачи.

refcount_t sk_wmem_alloc;

Атрибут sk_socket — это обратная ссылка на структуру socket, в которой содержится данный экземпляр структуры sock.

Это нужно, так как в некоторых случаях требуется обратная ссылка на сторону приложения для обработки, например SIGIO. Характерный пример такого приложения — демон inetd.

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

// Флаги TCP Small Queues — механизма, предотвращающего раздувание

// буферов сокета путем ограничения числа сегментов, которые могут быть

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

unsigned long sk_tsq_flags;

union

{

// Начало передаваемых данных.

struct sk_buff *sk_send_head;

// Очередь повторной передачи TCP.

struct rb_root tcp_rtx_queue;

};

// Очередь отправляемых пакетов.

struct sk_buff_head sk_write_queue;

// Замена более раннего атрибута dst_confirm, подтверждающего полученные

// пакеты. Нужен для подтверждения соседей.

u32 sk_dst_pending_confirm;

// Состояние планировщика: SK_PACING_NONE, SK_PACING_NEEDED, SK_PACING_FQ.

// Влияет на скорость отправки потока, связан с параметром

// sk_pacing_rate. Подробнее — в описании параметра maxrate утилиты tc.

u32 sk_pacing_status;

// Фрагмент страницы кэша.

struct page_frag sk_frag;

// Таймер очистки сокета.

struct timer_list sk_timer;

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

Некоторые опции, описанные далее, флаги, метки пакетов и настройки аллокаторов памяти и различных планировщиков, устанавливаемые через функцию setsockopt(), а иногда через ioctl(), также представляют собой «обычные атрибуты» данной структуры:

// Скорость передачи в байтах в секунду.

// Если поддерживается планировщиком транспорта/пакетов.

unsigned long sk_pacing_rate;

// Счетчик для устранения неоднозначности одновременных tstamp-запросов.

atomic_t sk_tskey;

// Счетчик для упорядочения уведомлений MSG_ZEROCOPY.

atomic_t sk_zckey;

// Значение опции SO_MAX_PACING_RATE.

unsigned long sk_max_pacing_rate;

// Значение SO_SNDTIMEO.

long sk_sndtimeo;

// Значение приоритета SO_PRIORITY.

u32 sk_priority;

// Общая метка пакета.

u32 sk_mark;

// Кэш назначения.

struct dst_entry __rcu *sk_dst_cache;

// Возможности маршрута, например NETIF_F_TSO. Буквально возможности,

// которые поддерживает сетевой адаптер, например GRO или расчет CRC.

netdev_features_t sk_route_caps;

// Тип GSO, например SKB_GSO_TCPV4.

int sk_gso_type;

// Максимальное число сегментов GSO.

u16 sk_gso_max_segs;

// Максимальный размер сегмента GSO для построения.

unsigned int sk_gso_max_size;

// Флаги выделения памяти для буферов, например GFP_ATOMIC.

gfp_t sk_allocation;

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

u32 sk_txhash;

// Коэффициент масштабирования для маленьких TCP-очередей — TSQ.

u8 sk_pacing_shift;

// Разрешить sk_page_frag() использовать атрибут сокета task_frag.

// Сокеты, которые можно использовать при освобождении памяти,

// должны установить значение false. Это сокеты, которые не используются

// сетевыми блочными устройствами, файловыми системами и т.п.

bool  sk_use_task_frag;

// Если флаг установлен, NETIF_F_GSO_MASK запрещена.

u8 sk_gso_disabled : 1,

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

sk_kern_sock : 1,

// Опция SO_NO_CHECK, установка контрольной суммы

// в отправляемых пакетах.

sk_no_check_tx : 1,

// Разрешить нулевую контрольную сумму в принимаемых пакетах.

sk_no_check_rx : 1;

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

Именно в sock будут установлены семейство протоколов и тип сокета, которые задаются как параметры вызова функции socket(). Тут же сохраняется и значение параметра backlog, переданного функции listen():

// Маска SEND_SHUTDOWN и RCV_SHUTDOWN.

u8 sk_shutdown;

// Тип сокета, например SOCK_STREAM.

u16 sk_type;

// Протокол сокета в этом сетевом семействе.

u16 sk_protocol;

// Значение опции SO_LINGER.

unsigned long sk_lingertime;

// sk_prot создателя сокетов, например IPV6_ADDRFORM.

struct proto *sk_prot_creator;

// Блокировка, используемая с обратными вызовами в конце структуры.

rwlock_t sk_callback_lock;

// Ошибки, которые не приводят к сбою, но являются причиной

// постоянного сбоя, а не просто истечением времени ожидания.

int sk_err_soft;

// Текущий бэклог для listen, то есть количество входящих соединений.

u32 sk_ack_backlog;

// Значение параметра backlog, переданное в функцию listen().

u32 sk_max_ack_backlog;

// UID владельца сокета.

kuid_t sk_uid;

Некоторые поля структур могут дублироваться. Например, поле типа сокета присутствует как в структуре socket, которая была рассмотрена перед этим, так и в структуре sock. Это вполне нормальная практика. Структура sock более старая и является частью многих других структур.

Дальнейшую часть структуры мы пропустим. Она содержит опции SO_TIMESTAMPING, SO_PEERCRED, SO_TXREHASH, параметры контрольной группы, некоторые флаги сокета и т.п.:

// Блокировка, защищающая атрибуты sk_peer_pid и sk_peer_cred.

spinlock_t sk_peer_lock;

// SO_TIMESTAMPING связывает индекс PHC виртуальных часов PTP

// для отметки времени.

int sk_bind_phc;

// PID для этого конца сокета.

struct pid *sk_peer_pid;

// Значение опции SO_PEERCRED.

const struct cred *sk_peer_cred;

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

ktime_t sk_stamp;

// Количество операций отключения, выполненных на этом сокете.

int sk_disconnects;

// Включить пересчет хеша TX. Управляется SO_TXREHASH.

u8 sk_txrehash;

// Идентификатор часов, используемый для time-based-планировщика

// (планировщика по времени).

// Опция SO_TXTIME.

u8 sk_clockid;

// Установить deadline-режим для SO_TXTIME.

u8 sk_txtime_deadline_mode : 1,

// Установить режим уведомления об ошибках для SO_TXTIME.

sk_txtime_report_errors : 1,

// Неиспользуемые флаги txtime.

sk_txtime_unused : 6;

// Закрытые данные уровня RPC.

void *sk_user_data;

// Данные cgroup для контрольной группы.

struct sock_cgroup_data sk_cgrp_data;

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

Итак, вы получили представление о том, как устанавливаются опции на самом низком уровне и к чему приводят вызовы setsockopt() и ioctl().

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

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

void (*sk_state_change)(struct sock *sk);

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

void (*sk_data_ready)(struct sock *sk);

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

void (*sk_write_space)(struct sock *sk);

// Вызывается при наличии ошибок, например MSG_ERRQUEUE.

void (*sk_error_report)(struct sock *sk);

// Вызывается при обработке бэклога.

int (*sk_backlog_rcv)(struct sock *sk, struct sk_buff *skb);

// Вызывается во время освобождения сокета, то есть когда на него нет

// ссылок.

void (*sk_destruct)(struct sock *sk);

// Контейнер группы reuseport, содержащий параметры для SO_REUSEPORT.

struct sock_reuseport __rcu *sk_reuseport_cb;

// Используется в течение RCU grace period, в котором ожидается,

// что все клиенты очереди будут отключены.

struct rcu_head sk_rcu;

// Число ссылок на сетевой неймспейс. Требуется для того, чтобы

// неймспейс не разрушился, пока в нем есть сокеты.

netns_tracker ns_tracker;

};

Фактически сокет в ядре — это «объект», за исключением того, что он весь реализован на чистом C. Указатели на функции используются внутри ядра для вызова соответствующих обработчиков.

Структура sock_common

Адреса и, если требуется, порты хранятся в отдельной структуре, «заголовке» в структуре sock. Тип заголовка — это структура sock_common:

typedef __u32 __bitwise __portpair;

typedef __u64 __bitwise __addrpair;

// Минимальное представление сетевого уровня для сокетов.

// Заголовок для структур sock и inet_timewait_sock.

struct sock_common

{

union

{

// Выровненное объединение skc_daddr и skc_rcv_saddr.

__addrpair skc_addrpair;

struct

{

// IPv4-адрес источника.

__be32 skc_daddr;

// Связанный локальный IPv4-адрес.

__be32 skc_rcv_saddr;

};

};

union

{

// Значение хеша, используемое с таблицами поиска протоколов.

unsigned int skc_hash;

// Два u16 со значениями хеша, используемые в таблицах поиска UDP.

__u16 skc_u16hashes[2];

};

// Сгруппированные skc_dport и skc_num.

union

{

// __u32-объединение skc_dport и skc_num.

__portpair skc_portpair;

struct

{

// Плейсхолдер для inet_dport/tw_dport

__be16 skc_dport;

// Плейсхолдер для inet_num/tw_num

__u16 skc_num;

};

};

// Семейство адресов.

unsigned short skc_family;

Некоторые флаги также хранятся в данной структуре:

// Состояние соединения.

volatile unsigned char skc_state;

// Значение параметра SO_REUSEADDR.

unsigned char skc_reuse:4;

// Значение параметра SO_REUSEPORT.

unsigned char skc_reuseport:1;

// Флаг указывает, что это исключительно IPV6-сокет.

unsigned char skc_ipv6only:1;

// Сокет использует подсчет ссылок net.

unsigned char skc_net_refcnt:1;

// Индекс связанного устройства, если не равен 0.

int skc_bound_dev_if;

Оставшаяся часть нам интересна полем skc_prot. Это структура, которая вносит особенности транспортных протоколов в сокет. Как это делается, будет рассмотрено в следующем разделе.

Структура может содержать IPv6-адреса, а также пространство для флагов и куки сокета:

// Обработчики протоколов сетевого уровня.

struct proto *skc_prot;

// Ссылка на сетевой неймспейс для этого сокета.

possible_net_t skc_net;

#if IS_ENABLED(CONFIG_IPV6)

// IPV6-адрес назначения.

struct in6_addr skc_v6_daddr;

// IPV6-адрес источника.

struct in6_addr skc_v6_rcv_saddr;

#endif

// Значение куки сокета.

atomic64_t skc_cookie;

// Поля используются как заполнение для 64-битных архитектур.

union

{

// Плейсхолдер для sk_flags SO_LINGER (l_onoff),

// SO_BROADCAST, SO_KEEPALIVE, SO_OOBINLINE,

// SO_TIMESTAMPING.

unsigned long skc_flags;

// Сокет прослушивателя запроса на соединение (rsk_listener).

struct sock *skc_listener; /* request_sock */

// Указатель на &struct inet_timewait_death_row.

struct inet_timewait_death_row *skc_tw_dr; /* inet_timewait_sock */

};

Прочие атрибуты — это номера очередей для TCP, привязка к CPU, счетчик ссылок:

// Номер очереди передачи для этого соединения.

unsigned short skc_tx_queue_mapping;

#ifdef CONFIG_SOCK_RX_QUEUE_MAPPING

// Номер очереди приема для этого соединения.

unsigned short skc_rx_queue_mapping;

#endif

union

{

// Привязка к процессору, обрабатывающему входящие пакеты.

int skc_incoming_cpu;

// Размер окна приема TCP (может изменяться, rsk_rcv_wnd).

u32 skc_rcv_wnd;

// Номер следующего ожидаемого сегмента в окне TCP (tw_rcv_nxt).

u32 skc_tw_rcv_nxt; /* struct tcp_timewait_sock  */

};

// Количество ссылок на сокет.

refcount_t skc_refcnt;

// Далее идут закрытые поля, которые тут не показаны.

// В них хранятся, например, служебные хеши.

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

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

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

Многие вызовы setsockopt() просто изменяют данные параметры, делая запросы к ядру.

Структура proto

Структура proto, которую хранит sk_prot в структуре sock_common, является главным связующим звеном между общими структурами и структурами для конкретных протоколов.

Помимо некоторых служебных атрибутов, основное содержимое этой структуры — указатели на функции:

struct proto

{

void (*close)(struct sock *sk, long timeout);

// Подключение и отключение сокета.

int (*pre_connect)(struct sock *sk, struct sockaddr *uaddr,

int addr_len);

int (*connect)(struct sock *sk, struct sockaddr *uaddr, int addr_len);

int (*disconnect)(struct sock *sk, int flags);

struct sock* (*accept)(struct sock *sk, int flags, int *err, bool kern);

// Установка некоторых опций.

int (*ioctl)(struct sock *sk, int cmd, unsigned long arg);

// Создание и уничтожение сокета (конструктор и деструктор).

int (*init)(struct sock *sk);

void (*destroy)(struct sock *sk);

void (*shutdown)(struct sock *sk, int how);

// Установка и получение опций.

int (*setsockopt)(struct sock *sk, int level, int optname,

sockptr_t optval, unsigned int optlen);

int (*getsockopt)(struct sock *sk, int level, int optname,

char __user *optval, int __user *option);

void (*keepalive)(struct sock *sk, int valbool);

Мы видим как уже известные названия — эти функции будут вызваны системными вызовами, — так и неизвестные, такие как keepalive.

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

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

#ifdef CONFIG_COMPAT

int (*compat_ioctl)(struct sock *sk, unsigned int cmd,

unsigned long arg);

#endif

// Отправка и получение данных.

int (*sendmsg)(struct sock *sk, struct msghdr *msg, size_t len);

int (*recvmsg)(struct sock *sk, struct msghdr *msg, size_t len,

int flags, int *addr_len);

int (*sendpage)(struct sock *sk, struct page *page, int offset,

size_t size, int flags);

int (*bind)(struct sock *sk, struct sockaddr *addr, int addr_len);

...

Данная структура содержит «методы» обработки сокета. Эти методы различаются для разных типов сокетов и применяются к различным экземплярам структур, которые передаются в параметре sk, по сути — this, если провести аналогию с C++.

Сами параметры могут быть структурами разных типов. Но все они содержат в начале структуру sock, которую можно называть «базовым классом».

Структура net_proto_family

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

struct net_proto_family

{

// Семейство адресов.

int family;

// Вызов для создания нового сокета.

int (*create)(net *net, struct socket *sock, int protocol, int kern);

// Модуль-владелец, поддерживающий семейство протоколов.

struct module *owner;

};

...

// Список семейств.

static const struct net_proto_family __rcu *net_families[NPROTO];

Видно, что существует список семейств — net_families, куда могут добавляться новые семейства — как ядром, так и модулями. Для их добавления используется функция sock_register().

INET-сокет

Любой интернет-сокет, работающий поверх IP, описан структурой inet_sock, определенной в net/inet_sock.h.

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

// struct inet_sock — представление INET-сокетов.

struct inet_sock

{

// "Класс"-предок.

struct sock sk;

#if IS_ENABLED(CONFIG_IPV6)

// Указатель на блок управления IPv6.

struct ipv6_pinfo *pinet6;

#endif

// Удаленный IPv4-адрес.

#define inet_daddr      sk.__sk_common.skc_daddr

// Связанный локальный IPv4-адрес.

#define inet_rcv_saddr  sk.__sk_common.skc_rcv_saddr

// Порт назначения.

#define inet_dport      sk.__sk_common.skc_dport

// Локальный порт.

#define inet_num        sk.__sk_common.skc_num

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

// Атомарные флаги: IP_RECVERR, отложенного соединения, того, что

// сокет ориентирован на соединение, а также флаги управляющих данных.

unsigned long inet_flags;

// Адрес отправителя.

__be32 inet_saddr;

// TTL для unicast-передачи.

__s16 uc_ttl;

// Порт источника.

__be16 inet_sport;

// Структура, которая содержит опции заголовка IP.

struct ip_options_rcu __rcu *inet_opt;

// Индикатор счетчика для пакетов DF, то есть таких, которые

// не фрагментируются.

__u16 inet_id;

// Поле IP-заголовка Type Of Service.

__u8 tos;

// Минимальный TTL.

__u8 min_ttl;

// TTL многоадресной рассылки.

__u8 mc_ttl;

// Состояние механизма Path MTU discovery.

// Принимает разные значения: IP_PMTUDISC_DO, IP_PMTUDISC_WANT,

// IP_PMTUDISC_OMIT, IP_PMTUDISC_INTERFACE, IP_PMTUDISC_PROBE.

__u8 pmtudisc;

Структура в конце содержит флаги, индексы сетевых интерфейсов, а также другую служебную информацию:

// ToS, принятый от удаленного абонента.

__u8 rcv_tos;

// Используется для расчета контрольной суммы заголовка.

__u8 convert_csum;

// Индекс устройства для одноадресной передачи.

int uc_index;

// Индекс устройства для многоадресной передачи.

int mc_index;

// Адрес для многоадресной рассылки.

__be32 mc_addr;

// Значение опции IP_LOCAL_PORT_RANGE.

u32 local_port_range;

// Массив групп многоадресной передачи.

struct ip_mc_socklist __rcu  *mc_list;

// Информация для построения ip hdr для каждого IP-фрагмента,

// сокета, на котором включен алгоритм Нейгла.

struct inet_cork_full cork;

};

Данная структура, «унаследованная» от sock, является «базовым классом» для протоколов, работающих в стеке TCP/IP. Схема наследования показана на рис. 7.1.

Рис. 7.1. Отношения между структурами Internet-сокета

Структуры транспортных протоколов

Сокеты транспортных протоколов содержат структуру inet_sock внутри своей, что хорошо видно на примере UDP:

struct udp_sock

{

// inet_sock — описание сокета IPv4.

struct inet_sock inet;

// Макросы для удобного доступа к полям.

#define udp_port_hash inet.sk.__sk_common.skc_u16hashes[0]

#define udp_portaddr_hash inet.sk.__sk_common.skc_u16hashes[1]

#define udp_portaddr_node inet.sk.__sk_common.skc_portaddr_node

// Флаги UDP.

unsigned long udp_flags;

// Количество ожидающих кадров.

int pending;

// Тип инкапсуляции, если она используется.

__u8 encap_type;

/*

* Следующие поля требуются для создания UDP-заголовка, когда

* сокет имеет снятый флаг UDP_CORK.

* В этом случае данные не объединяются в одну дейтаграмму.

*/

// Полный размер ожидающих кадров.

__u16 len;

__u16 gso_size;

// Дальше идут поля, специфичные для UDP Lite, указатели на функции

// для туннелированных сокетов и пр.

...

};

Опции для UDP представляют собой перечисление:

enum

{

// Включен флаг UDP_CORK.

UDP_FLAGS_CORK,

// Отправлять нулевые контрольные суммы UDP6.

UDP_FLAGS_NO_CHECK6_TX,

// Разрешено принимать нулевые контрольные суммы.

UDP_FLAGS_NO_CHECK6_RX,

// Включен Generic Receive Offload,

// то есть обработка прямо на сетевом устройстве.

UDP_FLAGS_GRO_ENABLED,

UDP_FLAGS_ACCEPT_FRAGLIST,

UDP_FLAGS_ACCEPT_L4,

// На сокете включена инкапсуляция, например, для туннелирования.

UDP_FLAGS_ENCAP_ENABLED,

// Опции для расчета контрольных сумм UDP Lite.

UDP_FLAGS_UDPLITE_SEND_CC,

UDP_FLAGS_UDPLITE_RECV_CC,

};

Флаги также перечислены в структуре sock_shared, но там мы это перечисление не показывали.

Структура proto для обработчиков UDP инициализируется следующим образом:

struct proto udp_prot =

{

.name            = "UDP",

.owner           = THIS_MODULE,

.close           = udp_lib_close,

.pre_connect     = udp_pre_connect,

// UDP поверх IPv6 — другой протокол, UDP6, который описывается

// структурой udpv6_prot.

.connect         = ip4_datagram_connect,

.disconnect      = udp_disconnect,

// Сюда перенаправляется вызов ioctl() для UDP.

.ioctl           = udp_ioctl,

// Эта функция инициализирует сокет (struct sock).

.init            = udp_init_sock,

.destroy         = udp_destroy_sock,

// Функции, которые обслуживают API для установки и получения опций

// сокета.

.setsockopt      = udp_setsockopt,

.getsockopt      = udp_getsockopt,

// Обмен данными осуществляется через sendmsg()/recvmsg().

.sendmsg         = udp_sendmsg,

.recvmsg         = udp_recvmsg,

//

.sendpage        = udp_sendpage,

.release_cb      = ip4_datagram_release_cb,

.hash            = udp_lib_hash,

.unhash          = udp_lib_unhash,

.rehash          = udp_v4_rehash,

.get_port        = udp_v4_get_port,

.put_port        = udp_lib_unhash,

...

.obj_size        = sizeof(struct udp_sock),

.h.udp_table     = NULL,

.diag_destroy    = udp_abort,

};

Например, поле sendmsg() в структуре udp_prot ссылается на функцию udp_sendmsg(), которая формирует UDP-дейтаграмму:

int udp_sendmsg(struct sock *sk, struct msghdr *msg, size_t len);

Функция updv6_sendmsg(), которая используется для формирования UDPv6-дейтаграммы, в определенных случаях вызывает udp_sendmsg().

Но с точки зрения сетевого стека в ядре Linux UDPv4 и UDPv6 — разные протоколы.

Функция достаточно объемная. При желании ее можно посмотреть в исходном коде ядра в файле net/ipv4/udp.c.

А вот протокол UDP Lite, хотя и описан структурой udplite_prot, использует большинство функций из протокола UDP, то есть вызов «методов предка», когда это требуется, может выполняться явно. Однако есть и другой механизм, который мы рассмотрим чуть позже.

Для приема данных используется поле recvmsg, которое в экземплярах udp_prot инициализируется адресом функции udp_recvmsg().

В tcp_prot эти поля инициализируются адресами функций tcp_sendmsg() и tcp_recvmsg() соответственно:

struct proto tcp_prot =

{

.name               = "TCP",

.owner              = THIS_MODULE,

.close              = tcp_close,

.pre_connect        = tcp_v4_pre_connect,

.connect            = tcp_v4_connect,

.disconnect         = tcp_disconnect,

.accept             = inet_csk_accept,

.ioctl              = tcp_ioctl,

.init               = tcp_v4_init_sock,

.destroy            = tcp_v4_destroy_sock,

.shutdown           = tcp_shutdown,

.setsockopt         = tcp_setsockopt,

.getsockopt         = tcp_getsockopt,

...

.diag_destroy       = tcp_abort,

};

Работа стека

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

Инициализация

В функции inet_init(), которая вызывается при инициализации сетевой подсистемы ядра, вызывается функция proto_register(), создающая в памяти экземпляры данных структур для каждого протокола:

static int __init inet_init(void)

{

struct inet_protosw *q;

struct list_head *r;

int rc;

// Регистрация протоколов.

...

rc = proto_register(&tcp_prot, 1);

if (rc) goto out;

rc = proto_register(&udp_prot, 1);

if (rc) goto out_unregister_tcp_proto;

rc = proto_register(&raw_prot, 1);

...

rc = proto_register(&ping_prot, 1);

// Регистрация AF_INET-семейства.

(void)sock_register(&inet_family_ops);

...

Видим, что после регистрации протоколов выполняется регистрация семейства адресов. Для AF_INET обработчиком создания нового сокета является функция inet_create():

static const struct net_proto_family inet_family_ops =

{

.family = PF_INET,

// Функция для создания нового сокета.

.create = inet_create,

.owner = THIS_MODULE,

};

Затем описатели протоколов добавляются в массив с помощью следующей функции:

struct net_protocol __rcu *inet_protos[MAX_INET_PROTOS];

...

int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol)

{

return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],

NULL, prot) ? 0 : -1;

}

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

struct net_protocol

{

// Обработчик SDU протокола нижележащего уровня.

int (*handler)(struct sk_buff *skb);

// Возвращает ошибку, если не получается ее устранить.

int (*err_handler)(struct sk_buff *skb, u32 info);

unsigned int no_policy:1,

icmp_strict_tag_validation:1;

u32 secret;

};

В функции inet_init() добавление выглядит так:

// Добавление базовых протоколов.

// ICMP.

if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)

pr_crit("%s: Cannot add ICMP protocol\n", __func__);

// UDP.

net_hotdata.udp_protocol = (struct net_protocol)

{

// Обработчик, который получает SDU, то есть IP-пакет

.handler = udp_rcv,

.err_handler = udp_err,

.no_policy = 1,

};

if (inet_add_protocol(&net_hotdata.udp_protocol, IPPROTO_UDP) < 0)

pr_crit("%s: Cannot add UDP protocol\n", __func__);

// TCP.

net_hotdata.tcp_protocol = (struct net_protocol)

{

.handler = tcp_v4_rcv,

.err_handler = tcp_v4_err,

.no_policy = 1,

.icmp_strict_tag_validation = 1,

};

if (inet_add_protocol(&net_hotdata.tcp_protocol, IPPROTO_TCP) < 0)

pr_crit("%s: Cannot add TCP protocol\n", __func__);

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

Завершается функция inet_init() вызовом инициализации протоколов:

...

// Регистрация информации сокета для inet_create().

for (r = &inetsw[0]; r < &inetsw[SOCK_MAX]; ++r)

INIT_LIST_HEAD(r);

for (q = inetsw_array; q < &inetsw_array[INETSW_ARRAY_LEN]; ++q)

inet_register_protosw(q);

// Инициализация ARP. Создается кэш соседей. Регистрируются свойства

// в SysFS.

arp_init();

// Инициализация IP. Настраивается маршрутизация.

ip_init();

// Инициализация IPv4 mibs для каждого процессора.

if (init_ipv4_mibs()) panic("%s: Cannot init ipv4 mibs\n", __func__);

// Установка кэша TCP slab, настройка таймеров, умолчаний и т.п.

tcp_init();

// Установка ограничений памяти для UDP.

udp_init();

udplite4_register();

raw_init();

ping_init();

...

if (icmp_init() < 0)

panic("Failed to create the ICMP control socket.\n");

...

// Добавление параметров в ProcFS.

ipv4_proc_init();

...

}

Это настройка разных параметров, специфичных для конкретных протоколов, что выходит за рамки рассматриваемой темы.

Обработчик udp_recv(), формирующий PDU, должен получить заголовок пакета, рассчитать и проверить контрольную сумму пакета и вернуть дейтаграмму, если не было ошибок. Он вызывается функцией ip_local_deliver_finish(), которая заканчивает обработку PDU сетевого уровня, в данном случае IPv4.

Обработчик ошибки должен вернуть ее корректный код.

Но даже в случае UDP эти обработчики достаточно сложны: они могут отправлять сообщения ICMP, работать с памятью и т.п.

Рис. 7.2. Регистрация обработчиков

Структура inet_protosw объединяет свойства протокола и его операции.

Для каждой из структур в массиве inetsw_array в функции inet_init(), разной для каждого семейства адресов, будет вызвана функция регистрации inet_register_protosw().

В массиве сначала описаны транспортные протоколы:

/* При запуске мы вставляем все элементы из inetsw_array[] в

* связанный список inetsw.

*/

static struct inet_protosw inetsw_array[] =

{

{

.type = SOCK_STREAM,

.protocol = IPPROTO_TCP,

// Операции транспортного протокола, описанные выше.

.prot = &tcp_prot,

// Операции нижележащего сетевого протокола, в данном случае IPv4.

// Например, connect здесь будет установлен в адрес

// inet_stream_connect().

.ops =  &inet_stream_ops,

.flags = INET_PROTOSW_PERMANENT | INET_PROTOSW_ICSK,

},

{

.type = SOCK_DGRAM,

.protocol = IPPROTO_UDP,

.prot = &udp_prot,

// Здесь connect будет установлен в адрес inet_dgram_connect().

.ops = &inet_dgram_ops,

.flags = INET_PROTOSW_PERMANENT,

},

Затем служебные — протокол ICMP и raw-протокол, то есть raw-сокеты без транспортного протокола:

{

.type = SOCK_DGRAM,

.protocol = IPPROTO_ICMP,

.prot = &ping_prot,

.ops = &inet_sockraw_ops,

.flags = INET_PROTOSW_REUSE,

},

{

.type = SOCK_RAW,

.protocol = IPPROTO_IP,

.prot = &raw_prot,

.ops = &inet_sockraw_ops,

.flags = INET_PROTOSW_REUSE,

}

};

Протоколов UDP Lite или протокола IGMP в этой структуре нет. Они добавляются отдельно. Для UDP Lite вызывается функция updlite4_register(), которая внутри себя вызывает proto_register(), inet_add_protocol() и inet_register_protosw().

Структура inet_protosw достаточно проста:

// Используется для регистрации сокетных интерфейсов IP-стека.

struct inet_protosw

{

// Структура — элемент списка.

struct list_head list;

// Два поля ниже используются для поиска.

// Второй аргумент сокета.

unsigned short type;

// Номер протокола транспортного уровня.

unsigned short protocol;

// Операции протокола.

struct proto* prot;

// Операции нижележащего протокола.

const struct proto_ops* ops;

// Флаги протокола: невозможность его удалить (выгрузить модуль),

// возможность переиспользовать порты протокола автоматически,

// ориентация на соединение.

unsigned char flags;

};

Единственный атрибут, который требует пояснения, — ops. Он содержит указатель на операции нижележащего уровня:

struct proto_ops

{

int family;

struct module*    owner;

int (*release)(socket *sock);

int (*bind)(socket *sock, sockaddr *myaddr, int sockaddr_len);

int (*connect)(socket *sock, struct sockaddr *vaddr,

int sockaddr_len, int flags);

int (*socketpair)(socket *sock1, struct socket *sock2);

int (*accept)(socket *sock, struct socket *newsock, int flags, bool kern);

int (*getname) (socket *sock, struct sockaddr *addr, int peer);

__poll_t (*poll)(file *file, struct socket *sock,

poll_table_struct *wait);

int (*ioctl)(socket *sock, unsigned int cmd, unsigned long arg);

...

int (*sendmsg)(socket *sock, msghdr *m, size_t total_len);

int (*recvmsg)(socket *sock, msghdr *m, size_t total_len, int flags);

int (*mmap)(file *file, socket *sock, vm_area_struct * vma);

ssize_t (*sendpage)(socket *sock, page *page, int offset, size_t size,

int flags);

...

};

Для транспортных протоколов, UDP и TCP, это будет уровень IP, а также указатели на функции, которые реализованы для конкретного протокола. Например, для TCP-сокета вызов poll() свой.

Для сокетного уровня это интерфейс системных вызовов. Когда пользователь выполняет системный вызов, сначала вызывается функция из этой структуры, а затем, как правило, функция из структуры prot.

Пример структуры inet_stream_ops:

const struct proto_ops inet_stream_ops =

{

.family = PF_INET,

.owner = THIS_MODULE,

.release = inet_release,

.bind = inet_bind,

.connect = inet_stream_connect,

.socketpair = sock_no_socketpair,

.accept = inet_accept,

.getname = inet_getname,

.poll = tcp_poll,

.ioctl = inet_ioctl,

.gettstamp = sock_gettstamp,

.listen = inet_listen,

.shutdown = inet_shutdown,

.setsockopt = sock_common_setsockopt,

.getsockopt = sock_common_getsockopt,

.sendmsg = inet_sendmsg,

.recvmsg = inet_recvmsg,

...

}

Создание сокета

Вызов функции socket() в Linux приводит к системному вызову sys_socket(), запрашивая у ядра создание новой структуры для сокета и привязывая ее к определенному дескриптору в запросившем создание процессе.

Сначала осуществляется вызов функции __sys_socket_create(), которая устанавливает некоторые флаги и вызывает функцию __sock_create(). Внутри нее создается структура сокета и его файловый дескриптор, что подробнее описано в главе 13.

Далее сетевой стек создает описанные выше структуры, которые специфичны для семейства протоколов, используя вызов create() структуры net_protofamily, выбранной из массива net_families по индексу семейства:

pf = rcu_dereference(net_families[family]);

...

err = pf->create(net, sock, protocol, kern);

Для AF_INET это будет функция inet_create(), которая делает следующее:

1. Вызывает sk_alloc() — функцию, создающую все сокетные объекты для протокола.

2. Вызывает sock_init_data(), инициализирующую различные структуры.

3. Вызывает sk->sk_prot->init() для завершения инициализации структур протокола.

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

Функция sk_alloc() использует вызовы из структуры proto для создания, а также инициализации нужных для работы протокола структур:

struct sock *sk_alloc(net *net, int family, gfp_t priority, proto *prot,

int kern)

{

struct sock *sk;

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

sk = sk_prot_alloc(prot, priority | __GFP_ZERO, family);

if (sk)

{

sk->sk_family = family;

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

sk->sk_prot = sk->sk_prot_creator = prot;

sk->sk_kern_sock = kern;

...

// Установка пространства имен.

sock_net_set(sk, net);

// Затем установка контрольной группы и т.п.

...

// Создание и очистка очереди на передачу.

sk_tx_queue_clear(sk);

}

return sk;

}

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

Прием и передача данных

К структурам применяются вызовы из структур операций, выбираемых на основе протокола. На рис. 7.3 показаны структуры tcp_prot и udp_prot.

Рис. 7.3. Работа стека Linux

Например, вызовы приема и отправки данных заканчиваются функциями ip_rcv() или ip_output(), которые формируют дейтаграмму и вызывают функцию отправки данных устройством.

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

С вызовами также связаны файлы в специальной файловой системе ProcFS.

Устройства

«Снизу» сетевой подсистемы находятся драйверы сетевых адаптеров, которые представлены модулями ядра.

При загрузке такой модуль вызывает функцию register_netdevice(), передавая ей структуру net_device:

struct net_device

{

// Сначала идут параметры GSO, которые не показаны.

// Флаги интерфейса.

unsigned int flags;

// Внутренние флаги.

unsigned long long priv_flags;

// Операции.

const struct net_device_ops *netdev_ops;

// Обратные вызовы для создания, анализа, кэширования и т.п.

// заголовков уровня 2.

const struct header_ops *header_ops;

// Массив очередей передачи.

struct netdev_queue *_tx;

...

// MTU интерфейса.

unsigned int mtu;

...

// Индекс данного интерфейса.

int ifindex;

В этой структуре хранится большинство параметров устройств:

// Имя сетевого интерфейса.

char name[IFNAMSIZ];

// Имя узла хеш-списка.

struct netdev_name_node *name_node;

// SNMP-маркировка интерфейсов.

struct dev_ifalias __rcu *ifalias;

// Адреса для ввода-вывода DMA.

unsigned long mem_end;

unsigned long mem_start;

unsigned long base_addr;

// Устройство добавляется в различные списки:

//   — Глобальный список устройств.

//   — Список опроса NAPI.

struct list_head dev_list;

struct list_head napi_list;

// Нужно, чтобы добавить устройство в список для удаления,

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

struct list_head unreg_list;

// Список для устройств, ожидающих закрытия.

struct list_head close_list;

// Списки обработчиков различных протоколов для этого устройства.

struct list_head ptype_all;

struct list_head ptype_specific;

...

// Назначенный устройству номер прерывания.

int irq;

// Основная структура для описания устройства.

struct device dev;

...

// Структура, описывающая устройство в подсистеме PHY.

struct phy_device * phydev;

...

};

Ядро и драйвер сетевого устройства взаимодействуют через прерывание, вызываемое пришедшими или отправленными устройством данными. Они записываются в буфер — структуру sk_buff, которая нам уже не раз встречалась.

Прием и передачу данных мы уже кратко рассмотрели в главе 3. Когда данные приняты, устройство также генерирует прерывание. Вызванный им обработчик добавляет задачу планировщику. В задаче будет выполнен вызов функций конкретных протоколов стека.

В случае, когда данные переданы, также генерируется прерывание, сигнализирующее о завершении передачи.

Номер прерывания, который назначен устройству, тоже хранится как атрибут структуры net_device.

Мы привели структуру в очень сокращенном виде. Она помогает лучше понять некоторые особенности функций, описанные в следующих главах. Например, почему функция ioctl(), работая с устройством, все равно получает дескриптор сокета.

Резюме

В ОС Linux вызов функции socket() приводит к системному вызову sys_socket(), запрашивая у ядра создание нового экземпляра структуры и привязывая его к определенному дескриптору в запросившем создание процессе.

Внутри ядра сетевой стек создает описанную структуру socket, которая является точкой входа для хранения информации о сокете и содержит еще несколько структур:

• socket — структура является представлением сокета в ядре и доступна системным вызовам.

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

• sock_common — хранит адреса и порты.

• proto — содержит методы обработки сокета.

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

Любой интернет-сокет, работающий поверх IP, описан структурой inet_sock. Структуры транспортных протоколов содержат эту структуру внутри своей, то есть «наследуются» от сетевого протокола. И далее в этом сокете применяются вызовы, описанные в этих структурах.

При инициализации сетевой подсистемы ядра вызывается функция proto_register(), создающая экземпляры структур sock и proto для каждого протокола. Затем — функции inet_add_protocol() и inet_register_protosw(), которые инициализируют «методы» конкретных сетевых протоколов, добавляя их в массив inetsw_array. Этот массив содержит структуры inet_protosw — связующее звено между структурами протоколов и функциями их обработки.

Создание нового сокета приводит к вызову функции create(), выбранной по семейству адресов из массива структур net_families, в котором свои функции предварительно зарегистрировали модули, поддерживающие разные семейства. Эта функция создает необходимые структуры и inode в специальной файловой системе SockFS, что позволяет работать через read() и write().

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

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

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

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

1. Для чего полезно изучать исходный код ядра Linux?

2. За что отвечает структура sock?

3. Внутри структуры sock есть указатель sk_socket на структуру socket. Для чего он нужен?

4. Что хранит структура sock_common?

5. Зачем при инициализации сетевой подсистемы ядра ОС создаются экземпляры структур sock и proto для каждого протокола?

6. Почему в ядре Linux структура сокета содержит специфичные атрибуты для разных протоколов?

7. Как взаимосвязаны структуры для IP и для TCP?

8. Каким образом можно управлять параметрами сокета в ядре Linux?

9. Для чего функции ioctl() передается дескриптор сокета при работе с устройством?

10. Где хранится информация о номере прерывания, назначенном сетевому адаптеру?

11. Какая структура сетевого стека объединяет свойства протокола и его операции?

12. В какую структуру записываются данные при обмене между ядром и драйвером сетевого устройства?

Глава 8. Управление сокетами

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

Стив Возняк, Debunks One of Apple's Biggest Myths, 2014

Введение

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

В этой главе мы подробнее рассмотрим, какие бывают опции и как их устанавливать. Мы коснемся опций, общих как для Unix, так и для ОС Windows, а также специфичных для Unix-подобных систем, и составим достаточно подробный их список для Linux.

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

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

Завершим главу рассмотрением функции fcntl(), предназначенной для работы с дескрипторами в Unix-подобных системах.

Данную главу можно использовать в качестве справочника при дальнейшей работе с книгой.

Функции setsockopt() и getsockopt()

Функции объявлены в файле sys/socket.h или winsock.h в Windows.

Прототип функции для установки параметра:

int setsockopt(int socket, int level, int option_name,

const void *option_value, socklen_t option_len);

И для его получения:

int getsockopt(int socket, int level, int option_name,

void *option_value, socklen_t *option_len);

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

• socket — дескриптор сокета.

• option_name — идентификатор параметра для установки или получения.

• level — уровень протокола:

• SOL_SOCKET — уровень библиотеки сокетов.

• Номер протокола для установки его опций. Может быть получен через функции getprotoent() и getprotobyname() либо задан константой, например IPPROTO_TCP. Также могут быть использованы SOL-опции: SOL_TCP, SOL_UDP и т.п., но этот вариант хуже поддерживается различными платформами.

• option_value — значение параметра. Тип не специфицирован. Он может быть любым, от int до произвольной структуры.

• option_len — длина параметра. Устанавливается как sizeof() значения параметра.

В случае успеха данные функции возвращают 0, в случае неудачи –1.

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

В различных ОС есть и другие функции. Например, в NetBSD есть функция getsockopt2():

int getsockopt2(int s, int level, int optname,

void * restrict optval, socklen_t * restrict optlen);

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

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

В Python данные функции представлены методами класса socket.socket:

@overload

def setsockopt(self, level: int, optname: int, value: int | bytes) -> None

@overload

def setsockopt(self, level: int, optname: int, value: None, optlen: int) -> None

@overload

def getsockopt(self, level: int, optname: int) -> int

@overload

def getsockopt(self, level: int, optname: int, buflen: int) -> bytes

Все параметры аналогичны параметрам C API, но Python-методы генерируют исключение в случае неудачи.

Поскольку чаще всего используются значения опций типа int, в Python для этого типа предусмотрена отдельная перегрузка socket.getsockopt() и socket.setsockopt().

Внимание! Если размер буфера не задан, метод socket.getsockopt() будет считать, что возвращаемое значение опции имеет тип int!

Пример использования функции для установки TTL IP-дейтаграмм:

extern "C"

{

#include <sys/socket.h>

#include <netinet/in.h>

#include <netinet/ip.h>

}

...

int ttl_val = 255;

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

// Установить опции сокета на уровне IP: TTL.

if (setsockopt(sock, SOL_IP, IP_TTL, &ttl_val, sizeof(ttl_val)) != 0)

{

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

"TTL setting failed");

}

Видим, что это опция IP. В функцию передается указатель на значение опции и ее размер. Напрямую передать константное значение опции нельзя.

Кроме того, опции могут быть разного типа, и передаваемое значение должно соответствовать тому, что ожидается для опции. Например, если ожидается тип int, не следует передавать значение типа char, даже если это может сработать.

Опции сокета

С некоторыми опциями вы уже встречались в книге. Мы повторим их и составим их общий список. Это полезно для понимания того, какие параметры можно настроить и каким образом. Список будет неполный: в некоторых ОС могут быть свои опции, а какие-то могут отсутствовать, и даже в разных версиях одной и той же ОС состав опций может различаться. Поэтому для того, чтобы пользоваться большей частью опций, придется обращаться к документации.

Опции разбиты по уровням и в Unix-подобных системах описаны на разных страницах руководства, обычно в разделе man «Socket Options»:

• man 7 socket — уровень сокетов.

• man 7 ip — уровень IP. Также здесь описаны некоторые параметры raw-сокетов, они описаны от уровня IP и выше.

• man 7 ipv6 — уровень IPv6.

• man 7 tcp — уровень TCP.

• man 7 unix — сокеты Unix-domain.

Сказанное справедливо и для других протоколов.

Опции сокетов ОС Windows традиционно описаны в MSDN42, где также можно посмотреть таблицу их поддержки в разных версиях ОС. Мы рассмотрим специфичные для ОС Windows опции в главе 18.

Некоторые параметры сокета устанавливаются не через опции, а через ioctl. Об этом подробнее см. в главе 10.

Уровень сокетов

Для установки и получения опций данного уровня используется параметр level, имеющий значение SOL_SOCKET. Префикс для опций SO_ означает Socket Option.

Опции, общие для Linux и Windows

Информационные опции, предназначенные только для чтения. Их нельзя установить через setsockopt(), только получить:

• SO_ACCEPTCONN — показывает, что сокет находится в режиме прослушивания или готовности принимать соединения, в который его переводит вызов listen(). Фактически опция просто возвращает значение флага, установленного вызовом listen().

• SO_TYPE — возвращает тип сокета, например SOCK_STREAM. В некоторых ОС может присутствовать аналогичная по смыслу опция SO_STYLE.

• SO_ERROR — получает и сбрасывает ошибку сокета.

Внимание! Значение ошибки может обновиться не сразу после того, как она возникла.

Опции маршрутизации:

• SO_BROADCAST — разрешить отправку широковещательных пакетов, если такая возможность поддерживается используемым протоколом. Позволяет установить или прочитать значение параметра.

• SO_DONTROUTE — устанавливает, должны ли исходящие данные отправляться на интерфейс, к которому привязан сокет, а не маршрутизироваться на какой-либо другой. То есть стек будет игнорировать таблицу маршрутизации.

Опция SO_DONTROUTE ведет себя по-разному в разных ОС. В ОС Windows установка будет успешной, но сокетный провайдер от Microsoft всегда использует таблицу маршрутизации для определения интерфейса, через который отправляются данные.

Тайм-ауты:

• SO_KEEPALIVE — проверять установленные соединения путем периодической передачи сообщений, если эта возможность поддерживается используемым протоколом. Опция — флаг типа bool либо int. Для TCP тайм-аут контролируется набором опций, которые описаны в разделе «Управление keep-alive» далее в этой главе.

• SO_RCVTIMEO — установить максимальный интервал, в течение которого функция приема ждет завершения. Если функция, принимающая данные, не завершается в течение указанного интервала, она возвращает либо частичный ответ, либо ошибку, если данные не были приняты. По умолчанию параметр равен 0, что означает отсутствие тайм-аута.

• SO_SNDTIMEO — параметр аналогичен предыдущему, но устанавливает тайм-аут на отправку данных.

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

В Linux тип параметра для опций SO_SNDTIMEO и SO_SNDTIMEO — структура struct timeval. В Windows — целое число миллисекунд.

Настройка отправки и приема данных:

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

• SO_OOBINLINE — для протоколов с установлением соединения указывает, что внеполосные данные отправляются вместе с обычными.

• SO_REUSEADDR — разрешить повторное использование локальных адресов, если такая возможность поддерживается используемым протоколом. Присутствует в Linux, начиная с версии ядра 2.4, а также в ОС Windows и BSD-системах. Позволяет использовать один и тот же порт на разных IP-адресах в рамках одной системы везде, кроме BSD-систем.

• SO_REUSEPORT — делает то, что в BSD-системах делает SO_REUSEADDR: разрешает нескольким процессам слушать на одном и том же порту и адресе. Появилась, начиная с версии ядра Linux 3.9. О том, зачем это нужно и как понять, куда обратился клиент, мы расскажем в книге 2.

Опция SO_LINGER принимает как параметр структуру linger43:

struct linger

{

// Linger включен.

int l_onoff;

// Число секунд тайм-аута.

int l_linger;

};

В Linux структура определена в linux/filter.h, в ОС Windows — в файле winsock.h.

Опции управления буферами:

• SO_RCVBUF — установить размер буфера приема сокета.

• SO_SNDBUF — установить размер буфера отправки.

Размер задается в байтах.

Для отладки и трассировки:

• SO_BSDCOMPAT — режим совместимости с BSD «баг в баг». Может быть полезен для переносимости и поиска ошибок.

• SO_DEBUG — включить запись отладочной информации. Результат применения опции не стандартизован и зависит от конкретной ОС:

• В BSD при включении этого параметра ядро будет сохранять для TCP-сокетов в своем внутреннем кольцевом буфере подробную информацию обо всех пакетах. Посмотреть ее можно, используя программу trpt.

• В Linux, судя по коду ядра, не делает ничего.

• В ОС Windows поведение зависит от сокетного провайдера, но в провайдерах от Microsoft, используемых по умолчанию, не дает никакого эффекта.

Linux-специфичные опции

Внимание! Некоторые из этих опций присутствуют и в ОС Windows, но там они не работают и представляют собой константы, не выполняющие действий.

Информационные, только для чтения:

• SO_DOMAIN — домен сокета, например AF_INET.

• SO_PROTOCOL — протокол, например IPPROTO_TCP.

• SO_PEERNAME — адрес удаленного узла, если подключен.

Привязка сокетов к сетевым интерфейсам:

• SO_BINDTODEVICE — получить имя связанного интерфейса, например eth0, или привязать интерфейс к сокету. Если передана пустая строка, интерфейс будет отвязан. Влияет как на исходящий, так и на входящий трафик. До Linux 3.8 была возможность только привязать или отвязать сокет, но не получить имя связанного устройства. Из-за этого данная опция гарантированно работает только для сокетов AF_INET, при возможности, например, когда у интерфейса есть IP-адрес, лучше использовать вызов bind(), что более переносимо. К тому же опция требует административных привилегий, чтобы переопределять настройки маршрутизации.

• SO_BINDTOIFINDEX — опция аналогична SO_BINDTODEVICE, но привязка выполняется по индексу. Это более надежный вариант, так как символьное имя сетевого интерфейса может измениться. Индекс — параметр типа int.

Оптимизации для работы с интенсивным трафиком:

• SO_CNX_ADVICE — connection advice, отчет о качестве соединения для ядра. Текущее поддерживаемое значение — 1. Оно говорит о том, что «маршрут плохой, требуется перенаправление». Ядро выполняет вызов dst_negative_advice, который попытается выбрать другой маршрут. Полезно для подключенных сокетов UDP и TCP, поскольку зачастую приложение оценивает качество связи в длительной перспективе лучше, чем ядро. Опция — флаг, который можно только сбросить или установить, но не прочитать. Тип — int.

• SO_INCOMING_CPU — установить или получить маску привязки сокета к CPU, где параметр — целое значение маски. Установить эту опцию можно, только начиная с версии ядра 4.

• SO_INCOMING_NAPI_ID — получить или установить уникальный идентификатор очереди приема. Дает возможность распределить обработку данных между разными потоками, которыми обрабатываются очереди. Каждый поток может работать с отдельным сетевым адаптером. Эта опция будет доступна, только если ядро Linux скомпилировано с CONFIG_NET_RX_BUSY_POLL.

• SO_PRIORITY — приоритет, зависящий от протокола. ОС будет в первую очередь обрабатывать на данном сокете пакеты с наивысшим приоритетом. Значения от 0 до 6 может установить пользователь. Для установки значений вне этого диапазона требуются привилегии CAP_NET_ADMIN или CAP_NET_RAW.

• SO_MAX_PACING_RATE — максимальная скорость отправки потока. По умолчанию не ограничена. Опция игнорируется, если она больше, чем значение, которое было задано через параметр maxrate утилиты tc. Подробнее см. в man 8 tc-fq.

• SO_TXTIME — задать режим дисциплины очередей Earliest TxTime First. Подробнее см. в man 8 tc-etf. Эта опция полезна для тестирования.

• SO_WIFI_STATUS — сообщает, был ли кадр подтвержден по Wi-Fi. Если опция активирована, будут поступать сообщения SCM_WIFI_STATUS, содержащие флаг, равный 0 или 1, где 1 означает, что кадр был подтвержден.

• SO_ZEROCOPY — предотвращает копирование больших буферов между пользовательским процессом и ядром, позволяя использовать обычные вызовы отправки данных через сокет без копирования.

Опция SO_ZEROCOPY временно разделяет буфер между процессом и сетевым стеком. После этого вместо копирования данных в буфер в произвольное время процесс должен ожидать уведомления, используя флаг MSG_ERRORQUEUE.

Подробнее об этом механизме будет рассказано в главе 23.

Опции расчета контрольных сумм:

• SO_NO_CHECK — отключить расчет контрольных сумм дейтаграмм. Влияет на различные протоколы, такие как UDP, iSCSI или DDP, в стеке AppleTalk. Для UDP отключает расчет контрольных сумм отправляемых дейтаграмм. Достаточно старая недокументированная опция, которую ранее не имело смысла использовать: в UDP расчет тратит очень мало ресурсов. Впоследствии действие опции расширилось на другие протоколы, и ее применение редко, но может быть оправданно. Например, в туннелях, где не требуется рассчитывать контрольную сумму внутреннего пакета.

• SO_TXREHASH — управляет пересчетом хеша для каждого сокета. По умолчанию сокеты отключают повторное хеширование при инициализации и используют значение из sysctl в состоянии прослушивания. При включении этого флага, если anycast-соединение, при котором данные отправляются ближайшему узлу из группы, завершается с ошибкой, система изменяет метку потока IPv6 и повторяет соединение, которое теперь может быть успешным, так как путь потока изменится. Опция появилась только в ядре версии 6.3.

• SO_NOFCS — отключить расчет FCS адаптером Ethernet. FCS или Frame Check Sequence — значение, добавляемое в конец кадра для обнаружения ошибок передачи принимающим абонентом, как показано на рис. 8.1. Обычно это CRC кадра. Если опция включена, вместо FCS будут использоваться последние четыре байта пакета, отправленного из пользовательского пространства.

Рис. 8.1. Поле FCS в Ethernet-кадре

Опции безопасности сокетов Unix-domain, некоторые из данных опций будут рассмотрены далее в этой главе, в разделе «Опции сокетов Unix-domain»:

• SO_PASSCRED — включить или выключить получение сообщения SCM_CREDENTIALS.

• SO_PASSPIDFD — включить или выключить получение сообщения SCM_PIDFD.

• SO_PASSEC — включить или выключить получение сообщения SCM_SECURITY.

• SO_PEERCRED — получить учетные данные однорангового процесса.

• SO_PEERPIDFD — аналог SO_PEERCRED, который включает или выключает получение сообщения SCM_PIDFD.

Управление буферами и памятью сокета:

• SO_PEEK_OFF — получить данные из очереди приема без их удаления. Поддерживается только для сокетов AF_UNIX.

• SO_RCVLOWAT — минимальное количество байтов в буфере приема, по достижении которого данные будут записаны в буфер пользователя. Опция существует и в ОС Windows, но она не поддерживается и нужна для обратной совместимости со старым BSD-стеком.

• SO_SNDLOWAT — минимальное количество байтов в буфере передачи, по достижении которого данные будут переданы на уровень протокола для отправки их в сеть. Опция всегда возвращает 1 и не может быть установлена. В ОС Windows работает так же, как предыдущая опция.

• SO_SNDBUFFORCE — установить размер буфера отправки, не обращая внимания на заданное ограничение размера. То же самое, что и SO_SNDBUF, но будет работать, только если процесс имеет административные привилегии CAP_NET_ADMIN.

• SO_RCVBUFFORCE — установить размер буфера приема, не обращая внимания на заданное ограничение размера. Требует привилегии CAP_NET_ADMIN.

• SO_RXQ_OVFL — количество пакетов, отброшенных с момента создания сокета. В некоторых системах это может быть количество пакетов, отброшенных с момента последнего сообщения, принятого сокетом. При включении поступит сообщение SO_RXQ_OVFL, содержащее 32-битное значение без знака.

• SO_BUF_LOCK — переключить автоматическую установку размера буферов. Принимает маску, в которой первый бит — флаг, отвечающий за буфер отправки, а второй — за буфер приема. Используется, например, проектом CRIU44, реализующим точки восстановления системы и миграцию ОС. Если после восстановления установить заданный размер буфера, автоматическая подстройка размера будет выключена. С помощью данной опции ее можно снова включить.

• SO_MEMINFO — позволяет читать SK_MEMINFO_VARS, то есть получить всю информацию, связанную с памятью, используя системный вызов.

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

Опция SO_SNDLOWAT не работает и нужна только для совместимости.

С опцией SO_RCVLOWAT есть сложности: ее не учитывают асинхронные механизмы select(), poll(), epoll. Они вернут управление, когда появится хотя бы один байт в буфере. Но если опция SO_RCVLOWAT установлена, выполнение потока блокируется, пока в буфер не будет получено заданное через эту опцию количество байтов.

Опция SO_MEMINFO вернет массив, который содержит значения по индексам:

• SK_MEMINFO_RMEM_ALLOC — объем данных в очереди приема.

• SK_MEMINFO_RCVBUF — размер буфера приема, заданный через SO_RCVBUF.

• SK_MEMINFO_WMEM_ALLOC — объем данных в очереди передачи.

• SK_MEMINFO_SNDBUF — размер буфера передачи, заданный через SO_SNDBUF.

• SK_MEMINFO_FWD_ALLOC — объем памяти, выделенный TCP для будущего использования.

• SK_MEMINFO_WMEM_QUEUED — объем данных, поставленных в очередь TCP, но еще не отправленных.

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

• SK_MEMINFO_BACKLOG — количество необработанных пакетов.

• SK_MEMINFO_DROPS — количество отброшенных пакетов.

Генерация меток времени:

• SO_TIMESTAMP — включить или отключить метку времени дейтаграмм. В сокет будут приходить управляющие сообщения SCM_TIMESTAMP, если опция включена.

• SO_TIMESTAMPNS — включить или отключить получение управляющего сообщения SCM_TIMESTAMPNS. Для получения метки используются часы реального времени. Конфликтует с SO_TIMESTAMP: одновременно можно использовать только одну из опций.

• SO_TIMESTAMPING — управляет генерацией временных меток. Значение — комбинация флагов:

• SOF_TIMESTAMPING_RX_HARDWARE — запросить временные метки принятых кадров от сетевого адаптера.

• SOF_TIMESTAMPING_RX_SOFTWARE — временные метки принятых данных, которые поступают в ядро. Метки времени генерируются сразу после того, как драйвер устройства передает пакет ядру.

• SOF_TIMESTAMPING_TX_HARDWARE — временные метки передаваемых кадров, созданные сетевым адаптером.

• SOF_TIMESTAMPING_TX_SOFTWARE — временные метки передаваемых данных, покидающих ядро. Эти метки времени генерируются в драйвере устройства до передачи пакета на сетевой интерфейс. Могут быть доступны не для всех устройств.

• SOF_TIMESTAMPING_TX_SCHED — временные метки передаваемых данных перед входом в планировщик пакетов. Вместе с предыдущим флагом позволяет точно измерить задержку в очереди.

• SOF_TIMESTAMPING_TX_ACK — временные метки всех подтвержденных данных в буфере отправки. Имеет смысл для надежных протоколов и реализован только для TCP.

• SOF_TIMESTAMPING_SOFTWARE — вернуть программные временные метки, если они доступны TX+RX.

• SOF_TIMESTAMPING_RAW_HARDWARE — вернуть аппаратные временные метки, созданные SOF_TIMESTAMPING_TX_HARDWARE, если они доступны.

В редких случаях в Linux могут встретиться следующие опции:

• SO_TIMESTAMPING_OLD, SO_TIMESTAMPING_NEW.

• SO_RCVTIMEO_OLD, SO_RCVTIMEO_NEW.

• SO_SNDTIMEO_OLD, SO_SNDTIMEO_NEW.

• SO_TIMESTAMPNS_OLD SO_TIMESTAMPNS_NEW.

Это макросы, которые реализуют соответствующие опции без суффиксов. Выбираются на основе размерности типа long и нужны, чтобы правильно выбрать тип счетчика времени. Явно ими пользоваться не следует, они нужны только для совместимости разных версий ABI.

Опции BPF:

• SO_ATTACH_FILTER, SO_DETACH_FILTER — добавить к сокету фильтр классического BPF или убрать фильтр.

• SO_ATTACH_BPF, SO_DETACH_BPF — добавить к сокету фильтр Extended BPF или убрать фильтр.

• SO_GET_FILTER — то же, что и SO_ATTACH_FILTER.

• SO_ATTACH_REUSEPORT_CBPF, SO_ATTACH_REUSEPORT_EBPF — вместе с опцией SO_REUSEPORT позволяет использовать классический или расширенный BPF, чтобы задать, как пакеты будут распределяться между сокетами, на которых установлен SO_REUSEPORT и которые имеют одинаковый локальный адрес. Программа BPF должна вернуть индекс сокета от 0 до N – 1, в противном случае выбирать сокет будет простой механизм SO_REUSEPORT. Сокеты нумеруются в порядке их добавления в группу.

• SO_LOCK_FILTER — установка этой опции запрещает менять фильтры, связанные с сокетом. Для изменения требуется привилегия CAP_NET_RAW.

• SO_ATTACH_REUSEPORT_CBPF, SO_ATTACH_REUSEPORT_EBPF — позволяет изменить политику работы опции SO_REUSEPORT для заданной пользователем BPF программы.

• SO_BPF_EXTENSIONS — вернуть расширения BPF. Опция только для чтения. Тип возвращаемого значения — int.

Опции SO_DETACH_FILTER и SO_DETACH_BPF одинаковы, то есть могут быть использованы для удаления фильтров BPF любого типа.

Функция, возвращающая расширения BPF в ядре, выглядит так:

static inline int bpf_tell_extensions(void)

{

return SKF_AD_MAX;

}

То есть это сейчас просто комбинация идентификаторов расширений.

Управление работой опроса занятости:

• SO_BUSY_POLL — устанавливает приблизительное время в микросекундах для опроса занятости при отсутствии данных в блокирующем режиме. Также влияет на время опроса функциями select() и poll(). Значение по умолчанию для этого параметра содержится в /proc/sys/net/core/busy_read. Для увеличения значения требуется привилегия CAP_NET_ADMIN.

• SO_PREFER_BUSY_POLL — включить режим Preferred Busy Polling, что может увеличить производительность для сокетов с большим трафиком и значительно улучшить производительность на одном ядре за счет того, что прерывания NAPI завершаются раньше, а опрос занятости возобновляется по сторожевому таймеру. Работает не только для обычных сокетов, но и для DPDK-драйвера семейства AF_XDP. DPDK будет рассмотрен в главе 23. Опция появилась в ядре версии 5.11.

• SO_BUSY_POLL_BUDGET — установить бюджет NAPI для каждого сокета опроса. По умолчанию — 8 микросекунд.

• SO_SELECT_ERR_QUEUE — если опция установлена, состояние ошибки в сокете вызывает уведомление не только через набор исключений функции select(), но и через poll(), который вернет POLLPRI, если наступит событие POLLERR. С Linux 4.16 использование этой опции для получения уведомлений больше не требуется. Опция сохранена для обратной совместимости. Подробнее о ней см. в man 7 socket.

Опции доступны, только когда ядро Linux скомпилировано с поддержкой опроса, то есть с параметром CONFIG_NET_RX_BUSY_POLL.

Busy polling, или опрос занятости, — способность пользовательского потока активно ожидать входящих сетевых сообщений, используя прямой опрос кольцевого буфера устройства, вместо традиционного способа ожидания, при котором заблокированный поток разблокируется прерыванием.

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

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

Значение в /proc/sys/net/core/busy_poll определяет, как долго select() и poll() будут выполнять опрос занятости, когда они работают с сокетами с установленным SO_BUSY_POLL и нет событий, о которых нужно сообщить.

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

Режим Preferred Busy Polling нужен из-за того, что некоторые асинхронные приложения рассчитаны на то, что большая часть обработки NAPI выполняется путем опроса занятости, а перегруженный NAPI контекст никогда не будет разрешать опрос занятости. В случае же использования этого режима обработка softirq может быть прервана сторожевым таймером и запущен опрос.

Прочие опции:

• SO_MARK — позволяет устанавливать на каждый пакет метку. Эта опция типа uint32 устанавливает значение поля в объекте ядра Linux. В результате появляется возможность маршрутизировать сообщения этого сокета по метке.

• SO_RCVMARK — получать в управляющих сообщениях метку, установленную администратором. Если опция установлена и метка задана, будет приходить сообщение SO_MARK. Требует привилегий CAP_NET_RAW или CAP_NET_ADMIN.

• SO_COOKIE — возвращает куки сокета, то есть непрозрачный уникальный идентификатор. Тип значения — uint64.

• SO_NETNS_COOKIE — возвращает куки сетевого пространства имен. Требуется, чтобы различать пространства имен, которым принадлежат сокеты. Тип опции — uint64.

• SO_SECURITY_AUTHENTICATION, SO_SECURITY_ENCRYPTION_TRANSPORT, SO_SECURITY_ENCRYPTION_NETWORK — уровни безопасности IPv6. Работа с ними пока не реализована.

Параметр SO_MARK может использоваться для работы межсетевого экрана с указанными сокетами. В Linux это IPTables.

Например, следующий вызов IPTables использует метку, чтобы ограничивать поток и журналировать пакеты с заданным префиксом:

iptables -A INPUT -m mark ! --mark 0 -m limit --limit 8/min -j LOG --log-prefix "Socket-Mark: ".

Уровень IP

Для установки опций требуется указать level, равный IPPROTO_IP или SOL_IP.

В Unix-подобных ОС для C++ необходимо включить заголовочный файл netinet/ip.h.

Опции групп многоадресной рассылки

В книге мы не будем рассматривать многоадресную передачу, но приведем опции, которые подробнее описаны в man:

• IP_ADD_MEMBERSHIP, IP_ADD_SOURCE_MEMBERSHIP, IP_DROP_MEMBERSHIP, IP_DROP_SOURCE_MEMBERSHIP — используя данные опции, можно управлять присутствием сокета в multicast-группах, добавлять и удалять его.

• IP_BLOCK_SOURCE, IP_UNBLOCK_SOURCE — управление получением данных от конкретных источников.

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

• IP_MULTICAST_IF — привязать сокет многоадресной рассылки к интерфейсу.

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

• IP_MULTICAST_TTL — установить или прочитать TTL для многоадресных пакетов.

• IP_MSFILTER — изменить фильтр многоадресных пакетов.

Опции связывания адресов и портов

Влияют на то, как работают функции bind() и connect():

• IP_BIND_ADDRESS_NO_PORT — информирует ядро не резервировать эфемерный порт, когда вызывается bind(). Порт будет выбран на этапе вызова connect(). При использовании опции в клиенте вызывается bind() с указанием source IP и портом 0. Опция уменьшает вероятность исчерпания пространства эфемерных портов. Используется, например, в Nginx или внутренними приложениями сервиса Cloudflare. Опция доступна в Linux с версии 4.2.

• IP_FREEBIND — флаг, разрешающий привязку к нелокальному или несуществующему IP-адресу. Включение опции позволяет ожидать подключения на сокете, для работы которого нет сетевого интерфейса. Опция может быть использована, например, в балансировщиках нагрузки, перенаправляющих обратный трафик на разные интерфейсы.

• IP_PORTSEL — эта опция присутствует в NetBSD, в Linux ее нет. С ее помощью можно указать алгоритм, по которому будет производиться автоматический выбор портов, например, как будут выбираться эфемерные порты: bsd, random_start, random_pick, hash, doublehash, randinc. Подробнее см. RFC 6056 «Recommendations for Transport-Protocol Port Randomization».

• IP_LOCAL_PORT_RANGE — задать диапазон выбора локального порта. Эфемерные порты выбираются из этого диапазона. Значение — uint32, первые 16 бит которого содержат нижнюю границу диапазона, вторые 16 бит — верхнюю. Диапазон по умолчанию настраивается в /proc/sys/net/ipv4/ip_local_port_range.

• IP_TRANSPARENT — включить прозрачное проксирование на этом сокете. Опция присутствует в Linux и дает возможность:

• привязываться к адресам, не являющимся локальными;

• получать соединения и пакеты от сеансов TPROXY, перенаправленных iptables.

• IP_UNICAST_IF — установить индекс интерфейса исходящих дейтаграмм. Влияет только на исходящий трафик и не требует административных привилегий для установки. Для сокетов, ориентированных на соединение, опция игнорируется. Тип значения — int. Опция была добавлена для проекта Wine.

Опция IP_TRANSPARENT обычно мало полезна, но мы рассмотрим некоторые ее возможные применения подробнее в главе 21.

Управление MTU и фрагментацией пакетов

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

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

• IP_MTU_DISCOVER — управление Path MTU Discovery или определением MTU на пути следования пакета. Также опция управляет флагом DF в IP-пакете. Опция принимает несколько значений:

• IP_PMTUDISC_WANT — использовать настройки для маршрута.

• IP_PMTUDISC_DONT — не выполнять определение MTU пути.

• IP_PMTUDISC_DO — всегда определять MTU пути.

• IP_PMTUDISC_PROBE — установить флаг DF — Don't Fragment, но игнорировать MTU пути.

• IP_NODEFRAG — если опция установлена, сетевой уровень не будет пересобирать исходящие пакеты. Опция корректна только для raw-сокетов.

• IP_RECVFRAGSIZE — установить или получить размер самого большого фрагмента пакета. Будет использовано для определения Path MTU. При чтении опция возвращается, только когда данные были фрагментированы.

Path MTU discovery используют достаточно редко, предпочитая использовать жестко заданный «безопасный» размер пакета. Однако правильнее установить размер из MTU пути. В новых приложениях по возможности его следует использовать.

Работа с опциями заголовка IP

Используя следующие константы, можно получить опции заголовка IP:

• IP_OPTIONS — установить или получить опции IP, которые будут отправляться с каждым пакетом из этого сокета в сообщении IP_OPTIONS. Значение — указатель на буфер памяти. Допустимые опции можно посмотреть в RFC 791 «Internet Protocol». Если они не заданы, поведение будет зависеть от вышележащего типа протокола. Например, для SOCK_STREAM первый входящий пакет установит значения опций, которые будут добавляться ко всем отправляемым пакетам. Вызов getsockopt() вернет опции IP, которые используются для передачи.

• IP_RECVOPTS — отправить все опции из входящих IP-дейтаграмм пользователю в управляющем сообщении IP_OPTIONS. Не поддерживается для сокетов SOCK_STREAM.

• IP_RETOPTS — опция идентична IP_RECVOPTS, но возвращает необработанные опции с отметкой времени и незаполненными параметрами записи маршрута. Используется для совместимости с системами BSD.

Для получения других опций используются константы:

• IP_PASSSEC, SO_PEERSEC — данные опции требуются при работе с IPSec, где отвечают за передачу контекста безопасности.

• IP_RECVTOS — если опция включена, сообщение IP_TOS передается с входящими пакетами в сообщении IP_TOS. Он содержит байт, определяющий поле типа обслуживания/приоритета в заголовке IP-пакета.

• IP_MINTTL — установить минимальное время TTL. Все пакеты с более низким TTL будут отброшены. Данная опция может использоваться для того, чтобы отбросить пакеты от внешних узлов, которые прошли в локальную сеть. Эта опция есть и во FreeBSD.

• IP_RECVTTL — если эта опция включена, можно получить управляющее сообщение IP_TTL с 32-битным полем времени жизни полученного пакета. Не поддерживается для сокетов SOCK_STREAM.

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

• IP_TOS — установить или получить значение поля Type-Of-Service для каждой IP-дейтаграммы, отправляемой из этого сокета. Используется для определения приоритетов пакетов в сети. Возможен один из следующих вариантов TOS:

• IPTOS_LOWDELAY — минимизировать задержки интерактивного трафика. В Linux по умолчанию устанавливается данный TOS.

• IPTOS_THROUGHPUT — оптимизировать пропускную способность.

• IPTOS_RELIABILITY — оптимизировать надежность.

• IPTOS_MINCOST — оптимизировать цену передачи. Следует использовать для «заполняющих данных», когда скорость передачи не имеет значения.

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

Внимание! В ОС Windows, начиная с XP, опция IP_TOS устарела и работать не будет.

Поле ToS в большинстве реализаций протокола IP равно 0 и не используется по назначению. Некоторые его биты могут быть использованы для ECN — явного уведомления о перегрузках.

Но изначальное предназначение — маршрутизация по типу обслуживания. Сейчас она является устаревшей частью IP.

Опции для отладки и трассировки

Про обработку ошибок и отладку мы будем говорить подробнее в главах 24 и 25. Здесь приведем опции, которые для этого могут быть полезны:

• IP_RECVERR — включить расширенную передачу сообщений об ошибках. Если эта функция включена на дейтаграммных сокетах, все сгенерированные ошибки будут помещены в очередь. Получить ошибку можно через вызов функции recvmsg() с флагом MSG_ERRQUEUE. Подробнее данная опция рассмотрена в главе 25.

• IP_RECVERR_RFC4884 — вернуть смещение начала структуры расширения для сообщений ICMP, если она присутствует. Расширения описаны в RFC 4884 «Extended ICMP to Support Multi-Part Messages».

• IP_PKTINFO — вызывает отправку служебного сообщения, содержащего информацию с адресами и связанным интерфейсом. Применима только для дейтаграммных сокетов. Тип значения — int, который содержит флаг, равный 0 или 1.

• IP_PKTOPTIONS — получать дополнительные опции пакета с дейтаграммами в сообщении IP_PKTINFO. Принимает указатель на буфер, как и IP_OPTIONS. Для приема опций размер этого буфера необходимо увеличить на 1024.

• IP_RECVORIGDSTADDR — включить отправку сообщения IP_ORIGDSTADDR, в котором ядро возвращает исходный адрес получателя пришедшей дейтаграммы. После включения адрес может быть получен через вызов функции recvmsg(). Эта опция возвращает оригинальный адрес назначения. Используется для получения адреса, например, вместе с опцией IP_TRANSPARENT, когда адрес назначения не совпадает с адресом локального узла.

• IP_CHECKSUM — если опция установлена, после завершения расчета контрольной суммы IP-пакета будет отправлено управляющее сообщение, содержащее ее значение.

Структура для IP_PKTINFO:

struct in_pktinfo

{

// Индекс интерфейса.

unsigned int ipi_ifindex;

// Локальный адрес.

in_addr ipi_spec_dst;

// Адрес назначения из заголовка.

in_addr ipi_addr;

};

Работа с raw-сокетами

Как уже говорилось, raw-сокеты работают поверх IP, поэтому управляются на уровне SOL_IP:

• IP_HDRINCL — опция, которая уже известна по работе с raw-сокетами и которая может быть использована только с ними. IP-заголовок пакета уже добавлен пользователем, что будет учтено стеком при отправке пакета.

• IP_ROUTER_ALERT — предоставляет механизм, с помощью которого маршрутизаторы перехватывают пакеты, не адресованные непосредственно им, без значительного падения производительности. Имеет тип int. Положительное число указывает значение опции заголовка Router Alert, которое будет индикатором перехвата, отрицательное — выключает перехват. Опция применима только к raw-сокетам.

Перехваченные пакеты не пересылаются ядром, повторно их отправить должен пользователь. Это полезно, например, для демонов RSVP в пользовательском пространстве или для работы с IGMP.

Опции IPSec

Помимо Linux, данные опции работают в NetBSD:

• IP_IPSEC_POLICY — установить политику IPSec.

• IP_XFRM_POLICY — настроить политику IP XFRM. Подробнее см. в man 8 ip-xfrm.

Опции требуют привилегии CAP_NET_ADMIN.

В NetBSD эта опция может быть использована следующим образом:

// Строка, задающая политику.

const char *policy = "in ipsec ah/transport//require";

// Буфер, в который "скомпилирована" политика.

char *buf = ipsec_set_policy(policy, strlen(policy));

setsockopt(sock, IPPROTO_IP, IP_IPSEC_POLICY, buf, ipsec_get_policylen(buf));

В Linux обе эти опции имеют одинаковое значение и отвечают за настройку политики IP XFRM. Если их значение нулевое, политика будет сброшена.

Внимание! Далеко не все приведенные опции IP поддерживаются всеми ОС. В некоторых ОС существуют опции, которые не вошли в список выше. Чтобы узнать, какие опции поддерживает ОС, на которой будет компилироваться и работать ваше приложение, обратитесь к документации.

Уровень IPv6

Для установки опций требуется указать level, равный IPPROTO_IPV6. Многие опции повторяют опции для IP.

В Unix-подобных ОС для C++ необходимо включить заголовочный файл netinet/ip6.h. Для некоторых Linux-специфичных опций требуется включить файл linux/in6.h.

Идентификаторы, которые содержат номер RFC, например IPV6_2292RTHDR, представляют собой устаревшие аналоги идентификаторов с тем же именем, но без номера.

Кроме того, в RFC 2292 «Advanced Sockets API for IPv6» для поля Next Header могут быть указаны названия констант, которые отличаются от названий в системе.

Опции для работы с многоадресными группами

Опции для работы с многоадресными группами в целом аналогичны опциям IPv4 и отличаются лишь префиксом IP6: IPV6_ADD_MEMBERSHIP, IPV6_DROP_MEMBERSHIP, IPV6_MULTICAST_LOOP, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_IF.

Опции связывания адресов и портов

Опции похожи на опции для IPv4:

• IPV6_PORTSEL — то же, что IP_PORTSEL, но для IPv6. Используется только для NetBSD.

• IPV6_TRANSPARENT — аналогична IP_TRANSPARENT.

• IPV6_FREEBIND — аналогична IP_FREEBIND.

Управление MTU и фрагментацией пакетов

Кроме опции, возвращающей значение Path MTU, остальные повторяют опции IPv4:

• IPV6_MTU — получить или установить MTU для сокета. Если включено обнаружение MTU, устанавливаемый MTU ограничивается MTU устройства или MTU пути.

• IPV6_MTU_DISCOVER — управление обнаружением path-MTU на сокете. Опция аналогична опции для IPv4.

• IPV6_RECVPATHMTU — включить получение управляющего сообщения IPV6_PATHMTU, содержащего найденный MTU пути.

• IPV6_DONTFRAG — не фрагментировать пакеты. Опция — флаг типа int. Аналога для IPv4 в Linux нет, хотя она есть в BSD-системах. В Linux IPv4 используйте для той же цели опцию IP_MTU_DISCOVER со значением IP_PMTUDISC_DO.

Включение доставки управляющих сообщений

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

Их также можно установить для исходящих пакетов, используя вызов sendmsg(), описанный далее:

• IPV6_RECVRTHDR — включить получение сообщения IPV6_RTHDR, содержащего заголовок маршрутизации.

• IPV6_AUTHHDR — включить получение сообщения IPV6_AUTHHDR, содержащего заголовок аутентификации.

• IPV6_RECVDSTOPTS — включить получение сообщения IPV6_DSTOPTS, содержащего параметры назначения.

• IPV6_RECVHOPOPTS — включить получение сообщения IPV6_HOPOPTS, содержащего параметры Hop-by-Hop: заголовок Hop-by-Hop options.

• IPV6_RECVHOPLIMIT — включить получение сообщения IPV6_HOPLIMIT, содержащего число переходов пакета.

• IPV6_RECVORIGDSTADDR — аналог IP_RECVORIGSTDADDR.

• IPV6_RECVFRAGSIZE — аналог IP_RECVFRAGSIZE.

• IPV6_RECVTCLASS — включить получение управляющего сообщения IPV6_TCLASS, содержащего класс трафика пакета.

Тип этих опций — bool. Управляющие сообщения имеют тот же тип, что и опция заголовка.

Потоки IPv6

Несколько опций для управления потоками данных IPv6:

• IPV6_AUTOFLOWLABEL — автоматически генерировать метку потока на основе хеша потока. Позволяет промежуточным устройствам, таким как маршрутизаторы, идентифицировать потоки пакетов для таких механизмов, как многопутевая маршрутизация. Флаг типа int.

• IPV6_FLOWINFO_SEND — флаг типа int, при включении которого отправляется сообщение IPV6_FLOWINFO, содержащее метку потока типа uint32.

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

Структура для опции IPV6_FLOWLABEL_MGR:

struct in6_flowlabel_req

{

// Адрес назначения IPv6, связанный с меткой.

struct in6_addr flr_dst;

// Значение метки потока в сетевом порядке байтов. 0 — случайная метка.

__u32 flr_label;

// Запрошенная операция: IPV6_FL_A_GET, IPV6_FL_A_PUT, IPV6_FL_A_RENEW.

__u8 flr_action;

// Кому разрешено повторно использовать одну и ту же метку потока:

// IPV6_FL_S_NONE, IPV6_FL_S_EXCL — частная метка,

// IPV6_FL_S_PROCESS — процессу, IPV6_FL_S_USER — пользователю,

// IPV6_FL_S_ANY — всем.

__u8 flr_share;

// Модификаторы:

// IPV6_FL_F_CREATE — создать новую метку,

// IPV6_FL_F_EXCL — не создавать новую метку.

__u16 flr_flags;

// Минимальное число секунд, которое метка существует.

__u16 flr_expires;

// После того как метка удалена, она не будет использована повторно

// минимум заданное число секунд.

__u16 flr_linger;

__u32 __flr_reserved;

// Далее могут идти опции в формате IPV6_PKTOPTIONS: IPV6_HOPOPTS,

// IPV6_RTHDR, IPV6_DSTOPTS.

};

Опции специфичны для Linux.

Опции, управляющие совместимостью IPv4 и IPv6

В главе 2 мы упоминали двойной IP-стек и работу с несколькими версиями IP одновременно. Следующие опции позволяют настроить данные режимы:

• IPV6_ADDRFORM — конвертировать сокет AF_INET6 в сокет другого семейства адресов. Поддерживается только AF_INET и разрешена только для сокетов IPv6, которые подключены и привязаны к адресу IPv4, отображенному на IPv6. Опция может быть использована для передачи файлового дескриптора сокета программам, которые не знают, как работать с API IPv6.

• IPV6_V6ONLY — включить для сокета работу только поверх IPv6. В этом случае пространства портов протоколов, работающих поверх IPv4 и IPv6, не будут пересекаться. Необходима для того, чтобы приложения IPv4 могли привязываться к тому же порту, что и приложения IPv6. Если опция установлена в 0, сокет можно использовать для отправки и получения пакетов на адрес IPv6 либо адрес IPv6, сопоставленный с IPv4.

• IPV6_UNICAST_HOPS — лимит одноадресных переходов для сокета: –1 — использование маршрута по умолчанию, в противном случае от 0 до 255.

• IPV6_MINHOPCOUNT — поле заголовка минимального количества переходов для входящих пакетов, полученных через сокеты TCP и UDP.

Опции для отладки и трассировки

Данные опции практически аналогичны опциям IPv4:

• IPV6_RECVERR — управление расширенной передачей сообщений ошибок. Опция аналогична такой же для IPv4.

• IPV6_RECVPKTINFO — включить доставку управляющего сообщения IPV6_PKTINFO для входящих дейтаграмм. Аналог IP_PKTINFO для IPv6. Сообщения представлены структурой in6_pktinfo. Поддерживается только сокетами типа SOCK_DGRAM и SOCK_RAW. Тип значения — int, содержащий флаг, равный 0 или 1.

• IPV6_2292PKTINFO — устаревшая опция, аналогичная IPV6_RECVPKTINFO.

• IPV6_PKTOPTIONS — аналог IP_PKTOPTIONS.

• IPV6_RECVERR_RFC4884 — аналог IP_RECVERR_RFC4884.

Работа с raw-сокетами

Для raw-сокетов доступна опция IPV6_ROUTER_ALERT, аналогичная опции IP_ROUTER_ALERT для IPv4.

Опцию IP_HDRINCL можно использовать на raw-сокетах IPv6 без изменения.

Внимание! Для IPv6 определен свой набор опций. Однако на IPv6-сокет можно установить некоторые опции IP-протокола, если указать для них уровень IPPROTO_IP. Важно следить, чтобы опции с префиксом IPV6_ всегда устанавливались для уровня IPPROTO_IPV6, а не IPPROTO_IP.

Уровень UDP

Для установки опций требуется указать level, равный IPPROTO_UDP или SOL_UDP.

В Unix-подобных ОС для C++ нужно включить заголовочный файл netinet/udp.h.

Несколько применимых опций:

• UDP_CORK — если эта опция включена, данные, отправляемые через сокет, объединяются в одну дейтаграмму, которая будет отправлена после выключения опции. Пример использования этой опции показан в книге 2, в главах о высокопроизводительных приложениях. Опция не кросс-платформенная, в ОС Windows есть ее аналог, рассмотренный в главе 18. В Linux доступна с версии 2.5.44.

• UDP_ENCAP — сокет будет принимать инкапсулированные пакеты. Опция принимает следующие значения:

• 0 — выключена.

• UDP_ENCAP_ESPINUDP_NON_IKE — не используется. Требовалось для прохождения NAT для IPSec, описанного в документе «Negotiation of NAT-Traversal in the IKE45».

• UDP_ENCAP_ESPINUDP — инкапсуляция IPSec-пакетов в UDP. Описана в «UDP Encapsulation of IPsec Packets draft-ietf-ipsec-udp-encaps-06.txt46».

• UDP_ENCAP_L2TPINUDP — инкапсуляция пакетов L2TP. Описана в RFC 2661 «Layer Two Tunneling Protocol L2TP». То есть UDP используется как транспорт для VPN.

• UDP_ENCAP_GTP0 — используется GPRS Tunnelling Protocol. В UDP инкапсулированы пакеты GSM TS 09.60, то есть GPRS для 2G.

• UDP_ENCAP_GTP1U — используется GPRS Tunnelling Protocol. В UDP инкапсулированы пакеты 3GPP TS 29.060. Это GPRS для 3G.

• UDP_ENCAP_RXRPC — UDP используется как транспорт для протокола RxRPC47. Требуется для безопасного вызова удаленных процедур сервера клиентом. Безопасный обмен ключами и все сложности обмена данными берет на себя ядро Linux.

• TCP_ENCAP_ESPINTCP — ESP в TCP поверх UDP.

• UDP_NO_CHECK6_TX — отключить отправку контрольной суммы для UDP при работе поверх IPv6.

• UDP_NO_CHECK6_RX — отключить прием контрольной суммы для UDP поверх IPv6.

• UDP_SEGMENT — включить разгрузку сегментации, установив размер сегмента GSO, то есть размер полезной нагрузки дейтаграммы, исключая UDP-заголовок. Может принимать значения от 0 до USHRT_MAX. Разгрузка сегментации снижает стоимость отправки данных за счет передачи нескольких дейтаграмм как одного большого пакета, даже если он превышает MTU. Доступна с Linux 4.18.

• UDP_GRO — включить GRO UDP. Если этот параметр включен, сокет может получать данные в нескольких дейтаграммах как один большой буфер вместе с управляющим сообщением, которое содержит размер сегмента. Доступна с Linux 5.0.

При включении GSO большой пакет разбивается на дейтаграммы, согласно MTU как можно позже:

1. Аппаратно сетевым адаптером, если эта возможность поддерживается.

2. Программно в ядре до вычисления контрольных сумм.

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

Внимание! При использовании GSO в UDP размер сегмента должен быть выбран так, чтобы за один вызов отправлялось не более 64 дейтаграмм.

Все опции, кроме первой, присутствуют только в Linux.

Из главы 7 вы могли заметить, что UDP и UDP6 — разные протоколы, то есть UDP6 — это не просто «UDP поверх IPv6». Но для удобства их опции перечислены в одном разделе.

Уровень UDPLite

Опции протокола UDP Lite, рассмотренного в книге 2. Для установки опций требуется указать level, равный IPPROTO_UDPLITE.

В Unix-подобных ОС для C++ достаточно включить заголовочный файл sys/socket.h.

На этом уровне всего две опции:

• UDPLITE_SEND_CSCOV — установить или получить количество байтов отправляемой дейтаграммы, которое покрывается контрольной суммой. Если значение 0 — покрывается вся дейтаграмма.

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

Уровень TCP

Для установки опций требуется указать level, равный IPPROTO_TCP или SOL_TCP.

В Unix-подобных ОС для C++ необходимо включить заголовочный файл netinet/tcp.h.

Внимание! Для TCP важно отметить, что большинство опций, установленных на прослушивающем сокете, будет унаследовано сокетом, возвращаемым accept().

Допустимы следующие опции:

• TCP_CONGESTION — строка, задающая алгоритм управления перегрузкой TCP для сокета. Непривилегированные процессы ограничены выбором одного из алгоритмов, указанных в /proc/sys/net/ipv4/tcp_allowed_congestion_control. Обычно это reno и cubic. Процессы с привилегией CAP_NET_ADMIN могут выбрать любой доступный алгоритм.

• TCP_MAXSEG — установить максимальный размер сегмента данных — MSS, то есть максимальное количество данных, отправляемых TCP в каждом сегменте. Нельзя установить больше, чем MTU.

• TCP_NODELAY — не задерживать отправку данных. Если параметр установлен, отключается алгоритм буферизации — алгоритм Нейгла48. Система будет отправлять данные сразу, как только они поступают. В результате могут отправляться слишком маленькие пакеты. Если параметр не установлен, данные буферизируются до тех пор, пока их не будет достаточно для отправки.

• TCP_NOOPT — не использовать опции TCP, которые были приняты в полях Options пакета, такие как максимальный размер сегмента, размер окна TCP и т.д.

• TCP_NOPUSH в BSD-системах или TCP_CORK в Linux — отключить немедленную отправку данных. Эта опция взаимоисключающая с TCP_NODELAY. Если она установлена, данные будут отправлены после ее отключения.

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

В Linux по умолчанию задано ограничение в 200 миллисекунд, в течение которого при установке TCP_CORK передача не производится. Если это время истекло, данные из очереди передаются автоматически.

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

• TCP_DEFER_ACCEPT — разрешить пробуждение сокета в режиме ожидания подключения только при поступлении данных. Принимает целое число секунд. Вызов accept() не будет пробуждаться, когда подключается клиент, а в течение указанного времени будет ожидать данные от него. В некоторых случаях это может улучшить производительность.

• TCP_NOTSENT_LOWAT — ограничить количество неотправленных байтов для сокета. Уменьшает использование памяти ядра.

• TCP_LINGER2 — задать время жизни сокетов в состояние FIN_WAIT2, то есть ожидающих ACK на свой FIN. По умолчанию — 60 секунд, но может быть переопределено на уровне системы.

• TCP_QUICKACK — переключить режим отложенного подтверждения. Если опция включена, ACK-пакеты отправляются немедленно и не откладываются.

• TCP_SYNCNT — задать количество повторных передач SYN, которые TCP должен отправить, прежде чем прервать попытку подключения. Максимальное значение — 255.

• TCP_WINDOW_CLAMP — ограничить размер окна приема. По умолчанию ядро устанавливает минимальный размер SOCK_MIN_RCVBUF / 2.

• TCP_ULP — установить протокол верхнего уровня: Upper Level Protocol, или ULP. Принимает строку, например tls. Позволяет использовать TLS на уровне ядра. Подробнее см. в книге 3.

• TCP_INQ — если эта опция установлена, recvmsg() передаст количество доступных байтов для чтения приложением из сокета через управляющее сообщение TCP_CM_INQ.

• TCP_ZEROCOPY_RECEIVE — получить адреса, в которые будут записаны принятые данные. Часть не копирующего API для TCP.

Внимание! Опция TCP_QUICKACK сбрасывается операциями стека. То есть, например, после вызова send() или recv() ее необходимо установить повторно.

В Linux имеется несколько полезных информационных опций:

• TCP_INFO — используется для получения информации о сокете. Ядро вернет структуру tcp_info, определенную в linux/tcp.h и содержащую метрики и прочие характеристики состояния TCP. В MacOS X эта константа называется TCP_CONNECTION_INFO.

• TCP_CC_INFO — используется для получения статистики алгоритма предотвращения перегрузок. Опция только для чтения. Принимает указатель на объединение tcp_cc_info.

Структуру tcp_info мы приводить не будем — в ней очень большое количество полей, а опция, которая эту структуру возвращает, используется не часто, поэтому заинтересованный читатель может найти информацию о ней самостоятельно.

Объединение tcp_cc_info приведем для примера. Оно состоит из набора структур, которые описывают статистику алгоритмов предотвращения перегрузок:

#include <linux/inet_diag.h>

// Статистика алгоритма Vegas.

struct tcpvegas_info

{

__u32 tcpv_enabled;

__u32 tcpv_rttcnt;

__u32 tcpv_rtt;

__u32 tcpv_minrtt;

};

// Статистика алгоритма DCTCP.

struct tcp_dctcp_info

{

__u16 dctcp_enabled;

__u16 dctcp_ce_state;

__u32 dctcp_alpha;

__u32 dctcp_ab_ecn;

__u32 dctcp_ab_tot;

};

// Для алгоритма BBR.

struct tcp_bbr_info

{

// u64 bw: максимальная полоса пропускания в байтах.

// Нижние 32 бита, следующее поле — верхние 32 бита.

__u32 bbr_bw_lo;

__u32 bbr_bw_hi;

// Минимально фильтруемый RTT.

__u32 bbr_min_rtt;

__u32 bbr_pacing_gain;

__u32 bbr_cwnd_gain;

};

Само объединение, передаваемое в опцию:

union tcp_cc_info

{

struct tcpvegas_info vegas;

struct tcp_dctcp_info dctcp;

struct tcp_bbr_info bbr;

};

Управление подключением

Назначение опций аналогично назначению расширенных функций connect():

• TCP_FASTOPEN — включить RFC 7413 «TCP Fast Open». Позволяет отправлять и принимать данные в SYN-пакете. Значение — количество ожидающих в очереди входящих SYN-сегментов, что по смыслу похоже на аргумент backlog в функции listen().

• TCP_FASTOPEN_CONNECT — включить альтернативный режим TFO. Если это первое соединение, connect() отправит в SYN-пакете SYN-cookie. Если же cookie уже есть, connect() добавит его в SYN и отложит соединение до первого вызова send(), который запишет отправляемые данные в SYN.

• TCP_FASTOPEN_KEY — установить значение cookie для TFO.

• TCP_FASTOPEN_NO_COOKIE — включить TFO без cookie.

Когда режим TFO включен, первый send() или recv() с флагом MSG_FASTOPEN соответственно отправит или примет данные. Подробнее TFO и расширенные функции соединения рассматриваются в книге 2.

В ОС Windows TFO будет работать только с функцией ConnectEx(), рассматриваемой в книге 2.

Несколько Linux-специфичных опций, которые позволяют серверу получать SYN-заголовки подключающихся по TCP клиентов:

• TCP_SAVE_SYN — записывать заголовки SYN-пакетов для новых соединений. Флаг типа int. Может быть установлен до или после вызова listen().

• TCP_SAVED_SYN — получить записанные заголовки SYN-пакетов. Опция только для чтения. Тип значения — массив, в который будут скопированы заголовки.

Это полезно для снятия отпечатков клиента с целью определения его ОС, идентификации на основе SYN-пакетов и подобного.

Восстановление соединения

Внимание! Описанные ниже опции используются только проектом CRIU. Другое прикладное их применение не предполагается. Мы приводим их только для того, чтобы вы понимали, что это такое, если они встретятся в коде.

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

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

В Linux существует «режим восстановления», поддерживающий такую миграцию. Когда сокет находится в этом режиме, можно выполнить следующие действия:

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

• Получить максимальное значение MSS, согласованное между двумя эндпоинтами во время установки соединения.

• Закрыть соединение без уведомления удаленной стороны. Пакеты FIN или RST отправляться не будут, поэтому удаленная сторона не будет знать, что что-то изменилось.

• Привязать сокет к заданному номеру порта без некоторых обычных проверок правильности.

• Восстановить порядковые номера TCP-последовательностей.

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

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

• TCP_REPAIR — перевести сокет в «режим восстановления». Сокет должен быть либо закрыт, либо подключен, то есть соединение должно иметь состояние ESTABLISHED. Требуется привилегия CAP_NET_ADMIN.

• TCP_REPAIR_QUEUE — выбрать очередь для восстановления:

• TCP_RECV_QUEUE — очередь приема;

• TCP_SEND_QUEUE — очередь передачи.

• TCP_MAXSEG — когда соединение находится в режиме восстановления, возвращает максимальное согласованное значение MSS, а не текущее активное значение.

• TCP_REPAIR_WINDOW — установить или получить параметры окна TCP.

Параметры окна в ядре описаны структурой:

struct tcp_repair_window

{

uint32_t snd_wl1;

uint32_t snd_wnd;

uint32_t max_window;

uint32_t rcv_wnd;

uint32_t rcv_wup;

};

Обычно поля в данной структуре для задачи восстановления менять не требуется. Но их назначение понятно из рис. 8.2.

Рис. 8.2. Окно TCP

После установки опции TCP_REPAIR_QUEUE с соответствующим параметром для чтения содержимого очереди необходимо вызвать recvmsg().

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

Чтобы восстановить сокет, требуется выполнить следующие действия, как показано на рис. 8.3:

1. Создать новый сокет.

2. Перевести его в режим восстановления.

3. Привязать к правильному номеру порта.

4. Вызвать TCP_REPAIR_QUEUE со значениями TCP_RECV_QUEUE и TCP_SEND_QUEUE, а затем вызвать sendmsg() для восстановления содержимого очередей отправки и получения.

5. Восстановить порядковые номера последовательностей отправки и получения.

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

Рис. 8.3. Восстановление сокета

Опции для установки данных, требуемых в целях миграции:

• TCP_QUEUE_SEQ — установить числа последовательностей. Требует предварительного выбора очереди с помощью TCP_REPAIR_QUEUE.

• TCP_REPAIR_OPTIONS — позволяет установить параметры, перечисленные в пункте 6.

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

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

В режиме восстановления соединение переходит в состояние ESTABLISHED без взаимодействия с удаленным абонентом.

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

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

Оптимизация тонких потоков

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

По умолчанию оптимизация работы с тонкими потоками выключена. Однако существует несколько опций для повышения эффективности работы с ними:

• TCP_THIN_LINEAR_TIMEOUTS — использовать линейные тайм-ауты для тонких потоков.

• TCP_THIN_DUPACK — включить повторную передачу после первого же DUP ACK.

Обе опции — это флаги со значением 0 или 1, поэтому имеют типы int. Они присутствуют только в Linux.

Управление keep-alive

По умолчанию, если для TCP-сокетов установлена опция SO_KEEPALIVE и активность отсутствовала в течение 2 часов, система отправит пакет keep-alive.

Если в течение 1 секунды в ответ будет получен ACK, система продолжит ожидание. Если в ответ будет получено RST- или ICMP-сообщение Host Unreachable, соединение будет разорвано. Иначе через каждые 75 секунд будет отправляться некоторое количество сообщений:

• 8 — в Unix-подобных ОС;

• 5 — в старых версиях ОС Windows;

• 10 — в новых ОС Windows.

При отсутствии на них ответа соединение будет разорвано. Параметры keep alive также можно установить через опции.

Опции, общие для Windows и Linux:

• TCP_KEEPCNT — максимальное количество запросов keep-alive, которые TCP должен отправить перед разрывом соединения.

• TCP_KEEPINTVL — количество секунд между отправкой пакетов keep-alive.

• TCP_KEEPIDLE — количество секунд, в течение которого соединение должно оставаться бездействующим, прежде чем TCP начнет отправлять пакеты keep-alive, если для сокета была установлена опция сокета SO_KEEPALIVE. В ОС Windows доступно второе название опции — TCP_KEEPALIVE.

Данные опции поддерживаются не всеми Unix-подобными системами, но они присутствуют как в Linux, так и в ОС Windows, начиная с версии 10.

В ОС Windows параметры keep-alive задаются в подразделе реестра HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters:

• KeepAliveTime — частота отправки сообщений проверки активности протоколом TCP, если удаленная система отвечает по данному протоколу. Значение по умолчанию — 2 часа.

• KeepAliveInterval — частота повторения передачи активности при отсутствии ответа. Значение по умолчанию — 1 секунда.

Значения параметров задаются в миллисекундах.

Количество проб keep-alive в Windows версии 10 и позже жестко задано и составляет 10.

Тайм-ауты

В Linux существует набор опций для работы с тайм-аутами в TCP:

• TCP_USER_TIMEOUT — если значение больше 0, задает максимальное количество миллисекунд, в течение которого передаваемые данные могут оставаться не подтвержденными или буферизованные данные могут оставаться не переданными, например, из-за нулевого размера окна, прежде чем TCP принудительно закроет соответствующее соединение и вернет ошибку ETIMEDOUT. Нулевое значение означает, что TCP использует системное значение по умолчанию.

• TCP_TIMESTAMP — управление опцией Timestamps в заголовке TCP, показанной на рис. 8.4. Позволяет установить или прочитать опцию. Младший бит содержит бит включения значения TSVal в микросекундах. Опция присутствует только в Linux.

• TCP_TX_DELAY — установить задержку передачи каждого пакета в микросекундах. Тип опции — unsigned int.

В ОС Windows опция TCP_USER_TIMEOUT называется TCP_MAXRT, во FreeBSD — TCP_KEEPINIT, в MacOS X — TCP_CONNECTIONTIMEOUT. Подробнее о них будет рассказано в книге 2.

Рис. 8.4. TCP-опция timestamp — метка времени

Аутентификация абонентов

В ядре Linux предусмотрена поддержка TCP-аутентификации51:

• TCP MD5 — описана в RFC 2385 «Protection of BGP Sessions via the TCP MD5 Signature Option».

• TCP AO — описана в RFC 5925 «The TCP Authentication Option» и RFC 5926 «Cryptographic Algorithms for the TCP Authentication Option (TCP-AO)». В TCP AO для обеспечения аутентификации используется цепочка ключей.

Обычно TCP-аутентификация используется при общении маршрутизаторов по BGP или сходным протоколам. Чтобы ее реализовать, добавляется новый TCP-заголовок, содержащий код аутентификации сообщения — Message Authentication Code, или MAC.

Внимание! Не следует путать MAC с MAC-адресом. Это одинаковые аббревиатуры с разным значением.

Опции TCP MD5:

• TCP_MD5SIG — добавить подпись MD5 для TCP-сокета.

• TCP_MD5SIG_EXT — установить длину префикса адреса для расчета подписи, которая будет распространяться на диапазон адресов. Если эта опция активирована и в tcpm_flags установлен флаг TCP_MD5SIG_FLAG_PREFIX, при расчете подписи будет учитываться длина префикса адреса.

TCP MD5 работает со следующей структурой:

#include <linux/tcp.h>

struct tcp_md5sig

{

// Ассоциированный адрес.

struct __kernel_sockaddr_storage tcpm_addr;

// Флаги расширения.

__u8 tcpm_flags;

// Длина префикса адреса.

__u8 tcpm_prefixlen;

// Длина ключа.

__u16 tcpm_keylen;

// Индекс интерфейса.

int tcpm_ifindex;

// Ключ в бинарном виде.

__u8 tcpm_key[TCP_MD5SIG_MAXKEYLEN];

};

Аутентификация TCP MD5 использует один ключ на соединение и не имеет защиты от таких атак, как повторение трафика.

Внимание! Этот вид аутентификации является устаревшим.

Заменяет его защищенная от атак TCP AO.

Опции TCP AO:

• TCP_AO_ADD_KEY — добавить главный ключ Master Key Tuple, или MKT.

• TCP_AO_DEL_KEY — удалить MKT.

• TCP_AO_REPAIR — в режиме восстановления получить SNE и ISN.

• TCP_AO_GET_KEYS — получить MKT.

• TCP_AO_INFO — установить или получить опции TCP-AO для каждого сокета.

Здесь:

• SNE — расширения порядковых номеров, которые используются для предотвращения атак повторного воспроизведения.

• ISN — начальные порядковые номера эндпоинта, на основе которых генерируются уникальные ключи защиты трафика, даже если MKT общий.

Чтобы ядро поддерживало TCP AO, оно должно быть скомпилировано с опцией CONFIG_TCP_AO. Без этого будут доступны опции чтения TCP_AO_GET_KEYS и TCP_AO_INFO, но не опции установки ключей.

Аналогично для поддержки TCP MD5 должна быть включена опция CONFIG_TCP_MD5SIG.

Опции raw-сокетов

Для установки опций требуется указать level, равный IPPROTO_RAW или SOL_IP:

• IP_HDRINCL — эту опцию мы уже рассматривали. Если она включена, при отправке IP-дейтаграммы пользователь должен сам заполнить некоторые поля IP-заголовка, например значение протокола. Обратите внимание, что данная опция имеет другое название для сокетов IPv6.

• ICMP_FILTER — определяет, какие типы ICMP-сообщений будет принимать сокет. Эта опция может быть использована, только если для raw-сокета указан протокол IPPROTO_ICMP.

На уровне IP для raw-сокетов также существуют опции, описанные выше, например IP_NODEFRAG.

Опции сокетов PF_PACKET

Представляют собой опции сокетов AF_PACKET более низкого уровня, чем raw-сокеты, и доступного только в Linux. Для установки данных опций требуется указать level, равный SOL_PACKET:

• PACKET_ADD_MEMBERSHIP и PACKET_DROP_MEMBERSHIP — добавить или удалить привязку. Настройка многоадресной рассылки и неразборчивого режима на физическом уровне. Аргумент — структура package_mreq. Используется в снифферах, которые мы разберем в главе 22.

• PACKET_AUXDATA — флаг включения передачи метаданных вместе с каждым пакетом. При включении данной опции будет передаваться вспомогательное сообщение, содержащее состояние пакета, например факт его корректности, длину, MAC и т.п.

• PACKET_FANOUT — режим масштабирования обработки по потокам:

• PACKET_FANOUT_HASH — отправить пакеты из одного и того же потока в один и тот же сокет. Для каждого пакета сокет выбирается по хешу от сетевого адреса и полям порта транспортного уровня.

• PACKET_FANOUT_LB — циклический алгоритм балансировки нагрузки.

• PACKET_FANOUT_CPU — выбрать сокет в зависимости от процессора, на который прибыл пакет.

• PACKET_FANOUT_ROLLOVER — обрабатывать все данные в одном сокете, переходя к следующему, когда сокет перегружен.

• PACKET_FANOUT_RND — выбрать сокет случайно.

• PACKET_FANOUT_QM — выбрать сокет, используя записанное значение queue_mapping полученного skb. Здесь queue_mapping — 16-битное число, которое можно установить, используя утилиту tc. Подробнее см. man 8 tc-skbedit. Иными словами, это явный выбор очереди для сокета.

• PACKET_LOSS — пропустить некорректный пакет. Если флаг установлен, любой неправильно сформированный пакет будет пропущен, его tp_status будет сброшен на TP_STATUS_AVAILABLE и передача данных продолжится. В противном случае, когда в кольцевом буфере передачи встречается некорректный пакет, его tp_status сбрасывается на TP_STATUS_WRONG_FORMAT, а передача немедленно прерывается. Чтобы ее возобновить, требуется исправить ошибку, сбросить tp_status в TP_STATUS_SEND_REQUEST и перезапустить процесс передачи через send().

• PACKET_RESERVE — зарезервировать место после метаданных и выравнивания. По умолчанию кольцо приема записывает пакеты сразу после структуры метаданных и выравнивания.

• PACKET_RX_RING — создать отображаемый в память кольцевой буфер для асинхронного приема пакетов.

• PACKET_TX_RING — создать отображаемый в память кольцевой буфер для передачи пакетов. Опция аналогична PACKET_RX_RING и принимает те же аргументы.

• PACKET_STATISTICS — получить статистику. Ее получение обнулит внутренние счетчики.

• PACKET_TIMESTAMP — выбрать тип метки времени. Кольцо приема сохраняет метку в метаданных. По умолчанию это метка, генерируемая, когда пакет копируется в кольцо. Помимо значения по умолчанию, поддерживает два аппаратных формата, описанных в документации ядра в networking/timestamping.rst.

• PACKET_VERSION — выбрать версию кольца приема. По умолчанию PACKET_RX_RING создает кольцо приема версии TPACKET_V1. Установить число перед созданием кольца можно, изменив версию.

• PACKET_QDISC_BYPASS — отключить буферизацию на уровне дисциплины очередей. По умолчанию пакеты проходят через уровень qdisc, но для некоторых случаев, например генераторов трафика, это поведение может быть полезно отключить. Это приведет к увеличению количества ошибок, когда очереди передачи сетевых устройств заняты.

Опции сокетов Unix-domain

По историческим причинам эти параметры сокета указываются с level-типом SOL_SOCKET, хотя они специфичны для AF_UNIX.

В Unix-подобных ОС для C++ необходимо включить заголовочный файл netinet/un.h.

Большая часть этих параметров возвращает данные о дескрипторах и процессах:

• SO_PASSCRED — включение этой опции сокета приводит к получению учетных данных отправляющего процесса во вспомогательном сообщении SCM_CREDENTIALS с каждым последующим сообщением. Возвращаемые учетные данные — это данные, которые указаны отправителем с помощью SCM_CREDENTIALS, или значения по умолчанию: PID отправителя, реальные идентификаторы пользователя и группы.

• SO_PASSSEC — разрешить получение метки безопасности SELinux однорангового сокета во вспомогательном сообщении типа SCM_SECURITY.

• SO_PEEK_OFF — при отрицательном значении recv() с флагом MSG_PEEK будет получать данные из начала очереди. Если для параметра установлено значение, большее или равное 0, следующий просмотр данных, находящихся в очереди в сокете, будет происходить со смещением в байтах, равным значению параметра. Смещение будет увеличиваться на количество байтов, которые были просмотрены из очереди, и следующий просмотр вернет следующие данные из очереди. Если данные удаляются из начала очереди с помощью вызова recv() без флага MSG_PEEK, смещение будет уменьшено на число удаленных байтов. В дейтаграммных сокетах, если смещение указывает на середину дейтаграммы, возвращаемые данные будут отмечены флагом MSG_TRUNC. По умолчанию –1.

• SO_PASSPIDFD — включить или выключить получение сообщения SCM_PIDFD, содержащего pidfd для отправляющего процесса.

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

• SO_PEERPIDFD — аналог SO_PEERCRED, который возвращает pidfd удаленного абонента вместо обычного pid, что позволяет разработчику не заботиться о проблеме повторного использования PID. Опция только для чтения. Тип возвращаемого значения — int.

• SO_PEERGROUPS — получить вспомогательные группы удаленного абонента. Расширяет SO_PEERCRED.

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

Опция SO_PEERCRED вернет структуру ucred:

#include <linux/socket.h>

struct ucred

{

// PID.

__u32 pid;

// ID пользователя — владельца процесса.

__u32 uid;

// ID группы — владельца процесса.

__u32 gid;

};

Опции сокетов Netlink

Сокеты Netlink служат для обмена данными между ядром и приложениями в Linux и некоторых BSD-системах. Их мы рассмотрим в главе 11, а сейчас только перечислим опции, применимые к данному типу сокетов:

• NETLINK_ADD_MEMBERSHIP и NETLINK_DROP_MEMBERSHIP — добавить сокет в группу или удалить из группы. Используется для подписки на сообщения.

• NETLINK_LIST_MEMBERSHIPS — получить все группы, членом которых является сокет. Значение — указатель на массив uint32, а optlen — размер массива.

• NETLINK_PKTINFO — присылать сообщения, содержащие расширенную информацию о группе.

• NETLINK_BROADCAST_ERROR — если установлено, netlink_broadcast() будет считать результат ENOBUFS ошибкой.

• NETLINK_NO_ENOBUFS — клиенты, ожидающие Netlink-сообщений, не будут получать ошибку ENOBUFS, говорящую о том, что размер буфера недостаточен. Это полезно, например, когда размер буфера статически установлен и лишние сообщения отбрасываются.

• NETLINK_LISTEN_ALL_NSID — если данная опция включена, сокет будет получать сообщения Netlink из всех сетевых пространств имен, которым назначен nsid, то есть идентификатор пространства имен, в котором был открыт сокет.

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

Прочие уровни

В структурах ядра существуют опции, которые не относятся к уровню сокета и протоколов. Например, атрибут sk_max_pacing_rate можно установить через опцию SO_MAX_PACING_RATE. Описание этой опции мы уже привели. Она устанавливает задержку между пакетами, которую добавляет планировщик Fair Queue, чтобы ограничивать скорость. Данная опция описана в man 8 tc-fq, а не в man 7 socket, и для нее используется константа уровня SOL_SOCKET.

Логично, что данная опция специфична для Linux, причем зависит от использования конкретного планировщика. Но эта опция не единственная.

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

В Linux, например, набор опций можно посмотреть в исходном коде ядра:

• Опции сокетов определены в файле include/uapi/asm-generic/socket.h. Все они перечислены в функциях sk_getsockopt() и sk_setsockopt(), реализованных в файле source/net/core/sock.c.

• Опции IP определены в файле include/uapi/linux/in.h. А их получение и установка выполняются функциями do_ip_getsockopt(), do_ip_setsockopt(), реализованными в файле source/net/ipv4/ip_sockglue.c.

Опции других уровней и протоколов находятся в других файлах, но принцип их поиска к настоящему моменту должен быть понятен.

Функция fcntl()

В BSD-системах и в Linux, особенно в системах типа Plan 9, дескрипторы сокетов аналогичны файловым дескрипторам, и поэтому к ним можно применять функцию fcntl(), специфичную для Unix-подобных систем. Функция fcntl() управляет файловым дескриптором и объявлена в файле fcntl.h.

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

Прототип функции:

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

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

• fd — файловый дескриптор, в нашем случае дескриптор сокета.

• cmd — выполняемая над дескриптором операция.

Остальные аргументы функции зависят от конкретной операции.

Функция в случае неудачи возвращает –1 или значение, которое зависит от выполненной операции.

Функция способна выполнять многие операции, напрямую к сокетам не относящиеся: дублирование файлового дескриптора, блокировку на чтение или запись и подобные. Здесь мы не будем их рассматривать, но читатель при желании всегда может обратиться к man 2 fcntl и man 3 fcntl.

С точки зрения сокетов интерес представляют несколько команд:

• F_GETFD и F_SETFD — получить или установить флаги дескриптора. Позволяет установить или сбросить флаг O_CLOEXEC.

• F_GETFL и F_SETFL — получить или установить флаги статуса. Используется для установки неблокирующего режима в Linux через флаг O_NONBLOCK (этот вопрос мы рассмотрим в следующих книгах). Ранее был рассмотрен ioctl, выполняющий аналогичную функцию.

• F_GETOWN и F_SETOWN либо F_GETOWN_EX и F_SETOWN_EX — получить или установить процесс или группу для получения сигналов SIGURG и SIGIO.

• F_GETSIG и F_SETSIG — получить или установить сигнал, который будет отправлен при доступности ввода или вывода. По умолчанию — SIGIO. Операция установки заменяет его на другой или возвращает SIGIO, если в качестве аргумента был передан 0.

• F_GETPIPE_SZ и F_SET_PIPE_SZ — получить или установить число байтов в буфере канала. Минимальное значение — размер страницы. Максимальное значение для непривилегированного процесса записано в /proc/sys/fs/pipe-max-size.

Эта же функция есть и в Python в упомянутом ранее модуле fcntl:

import fcntl

@overload

def fcntl(fd: FileDescriptorLike, cmd: int, arg: int = 0, /) -> int

@overload

def fcntl(fd: FileDescriptorLike, cmd: int, arg: str | ReadOnlyBuffer, /) -> bytes

Функция доступна только для Unix-подобных систем, в ОС Windows ее нет.

Рассмотрим пример, в котором сокет переводится в неблокирующий режим через fcntl():

#include <fcntl.h>

...

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

int non_block_enabled = 1;

if (fcntl(sock, F_SETFL, fcntl(sock, F_GETFL) | O_NONBLOCK) < 0)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

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

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

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

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

Резюме

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

Эти опции могут быть как общими между разными ОС, например Linux и Windows, так и специфичными для конкретной системы.

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

• setsockopt() — установить значение опции на уровне библиотеки сокетов или на уровне протокола.

• getsockopt() — получить значение опций сокета.

• sendmsg() — отправить управляющие данные.

• recvmsg() — принять управляющие данные.

Опции могут быть установлены на разных уровнях, от уровня сокета до уровней различных протоколов. Уровень задается константой. Для протоколов она, как правило, равна их коду, но имеет и специальное имя, например IPPROTO_IP и SOL_IP.

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

Состав опций уровня IPv6 по смыслу частично повторяет уровень IP, но с некоторыми отличиями: константы, сходные по смыслу, могут иметь префикс IPV6_; кроме того, на данном уровне присутствуют специфичные опции. Для установки параметров IPv6-сокета также используется часть опций уровня IP.

Похожим образом устроены опции уровней UDP и UDPLite.

На уровне TCP можно управлять подключением, восстановлением соединения, функциями keep-alive, тайм-аутами, а также включать и отключать различные оптимизации, которые зависят от свойств потоков данных.

Свой набор опций существует и для низкоуровневых сокетов, таких как raw-сокеты IP-семейства и сокеты PF_PACKET.

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

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

Существуют также опции, не принадлежащие к уровню сокета, но новую константу уровня вводить нежелательно, и поэтому разработчики часто добавляют их на уровень SOL_SOCKET.

Некоторые из опций вызывают отправку управляющих сообщений со вспомогательными данными, описанными в следующей главе. Такие сообщения, например, могут содержать PID процесса для сокетов AF_UNIX или опции заголовка IP для сокетов AF_INET.

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

В BSD-системах и в Linux дескрипторы сокетов аналогичны файловым дескрипторам, поэтому к ним можно применять функцию fcntl(), специфичную для Unix-подобных систем.

Функции setsockopt() и ioctl(), несмотря на свое назначение, представляют собой лабиринты параметров и команд, которые могут быстро запутать неподготовленного разработчика. Однако для создания высокопроизводительных и надежных сетевых приложений важно уметь работать с опциями правильно.

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

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

1. Что такое опция сокета?

2. Зачем обычно нужно считывать опции сокета перед их установкой?

3. Каким образом можно изменять опции сокетов?

4. Для чего нужны уровни опций и какие они бывают? Приведите два-три примера уровней.

5. Бывают ли опции, не относящиеся к определенному уровню? Если да, как их получить или установить?

6. Назовите три–пять, которые, по вашему мнению, были бы вам полезны в разработке сетевых приложений. В каких случаях вы будете использовать эти опции?

7. Встретились ли среди названных в ответе на предыдущий вопрос опции, специфичные для конкретной ОС? Что вы сделаете при переносе приложения туда, где нужной опции нет?

8. Что делает опция TCP_NODELAY? Когда ее использование может быть полезным или вредным?

9. Назовите хотя бы два метода определения состояния подключения.

10. Использование каких опций позволяет управлять TCP-соединением?

11. Как изменение размера буфера отправки или получения влияет на производительность обмена данными?

12. Что такое бесшовная миграция?

13. Зачем нужно отображение буферов отправки и передачи в пространство пользователя для пакетных сокетов?

14. Какое значение параметра level требуется указать для установки опций raw-сокетов?

15. Зачем используется функция fcntl()?

16. Напишите приложение, которое с помощью getsockopt() получает текущее значение МТU сокета и устанавливает новое.

42 https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-setsockopt

43 https://docs.microsoft.com/en-us/windows/win32/api/winsock/ns-winsock-linger

44 https://github.com/checkpoint-restore/criu

45 https://datatracker.ietf.org/doc/html/draft-ietf-ipsec-nat-t-ike-00

46 https://datatracker.ietf.org/doc/html/draft-ietf-ipsec-udp-encaps-06.txt

47 https://docs.kernel.org/networking/rxrpc.html

48 https://ru.wikipedia.org/wiki/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC_%D0%9D%D0%B5%D0%B9%D0%B3%D0%BB%D0%B0

49 https://github.com/checkpoint-restore/criu/

50 https://github.com/checkpoint-restore/criu/tree/criu-dev/soccr

51 https://docs.kernel.org/networking/tcp_ao.html

Глава 9. Вспомогательные данные

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

Э. Дейсктра, выступление перед выпускниками Колледжа естественных наук Техасского университета в Остине, 1996

Введение

Включение некоторых опций, например IP_RECVORIGDSTADDR, вызывает отправку сообщений, которые можно получить, вызвав функцию recvmsg(). Это «вспомогательные данные», или ancillary data. Эта глава будет посвящена работе с такими данными. Мы узнаем о типах вспомогательных данных, их структуре и применении в практических сценариях.

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

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

В рамках сокетов основное назначение вспомогательных данных — передача управляющей информации. Например, Linux использует механизм вспомогательных данных для передачи расширенных ошибок, опций IP или файловых дескрипторов через сокеты домена UNIX.

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

Внимание! Не стоит думать, что «вспомогательные данные» не важны. В английском языке они называются ancillary data. Слово ancillary имеет значение «поддержка, необходимая для основной деятельности». Поэтому эти данные управляющие, а не вспомогательные.

Функции sendmsg() и recvmsg()

Функции sendmsg() и recvmsg() представляют собой полноценный API для отправки и приема данных, расширяющий возможности описанных ранее функций. Он может заменить любую функцию приема и передачи данных.

Главные отличия этих функций:

• Возможность отправлять несколько единиц данных протокола за один вызов, что может улучшить производительность в нагруженных приложениях.

• В качестве аргумента им передается не буфер, а структура, которая содержит массив буферов.

• С помощью этих функций можно принимать и отправлять вспомогательные данные.

Для нас в контексте главы особый интерес представляет последний пункт. Только эти функции дают возможность обмениваться вспомогательными данными.

Рассмотрим прототипы функций:

#include <sys/socket.h>

ssize_t sendmsg(int socket, const msghdr *message, int flags);

ssize_t recvmsg(int socket, msghdr *message, int flags);

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

• socket — файловый дескриптор сокета.

• message — указатель на структуру msghdr.

• flags — определяет тип передачи или приема сообщения. Приложение может указать 0 или флаги, объединенные через побитовое «или».

Флаги, общие для приема и передачи:

• MSG_OOB — отправить или принять внеполосные данные.

• Флаги для передачи данных, такие как MSG_EOR, MSG_NOSIGNAL, MSG_DONTROUTE и MSG_CONFIRM, были рассмотрены при описании функций recvfrom() и sendto().

Флаги для приема данных:

• Флаг MSG_PEEK был описан для recvfrom().

• MSG_WAITALL — немного иначе работает в сокетах типа SOCK_STREAM. Он запрашивает блокировку функции до тех пор, пока не будет возвращен полный объем данных. Но функция может вернуть меньший объем данных в следующих случаях:

• Используется сокет на основе сообщений.

• Соединение было разорвано.

• Поступил сигнал.

• Был указан флаг MSG_PEEK.

• В сокете произошла ошибка.

• MSG_ERRQUEUE — получить ошибки из очереди. Будет работать при установленной опции сокета IP_RECVERR.

В некоторых версиях Linux для recvmsg() также могут быть доступны следующие флаги:

• MSG_CMSG_CLOEXEC — установить флаг CLOEXEC на дескриптор, полученный с помощью операции SCM_RIGHTS. Работает для сокетов AF_UNIX.

• MSG_TRUNC — для raw-сокетов и сокетов Netlink вернуть реальную длину пакета, даже если она превышает размер буфера.

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

Функции возвращают количество отправленных или принятых байтов либо –1. Но успешное завершение вызова sendmsg() не гарантирует доставку сообщения.

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

Внимание! Проблема этих функций в том, что они не переносимые. Например, в ОС Windows они отсутствуют, но есть аналогичные, зависящие от сервис-провайдера функции WSASendMsg() и WSARecvMsg(). Подробно они будут рассмотрены в главе 18.

Структура msghdr, показанная на рис. 9.1, определена в sys/socket.h следующим образом:

// Элементы массива для приема и передачи.

struct iovec

{

// Указатель на данные.

void *iov_base;

// Количество байтов для передачи.

size_t iov_len;

};

struct msghdr

{

// Необязательный адрес.

void *msg_name;

// Размер адреса.

socklen_t msg_namelen;

// Массив с данными.

struct iovec *msg_iov;

// Количество элементов в msg_iov.

size_t msg_iovlen;

// Вспомогательные данные.

void *msg_control;

// Размер буфера вспомогательных данных.

size_t msg_controllen;

// Флаги принятого сообщения.

// Игнорируются функцией sendmsg() при отправке.

int msg_flags;

};

В каждой структуре iovec поле iov_base указывает на буфер, а поле iov_len содержит размер буфера в байтах. Некоторые из этих размеров могут быть нулевыми.

Функции send() и sendto() в Linux — это оболочки для sendmsg(), которые создают структуру msghdr.

Например, реализация sendmsg() для UDP позволяет использовать один заголовок UDP для каждого вызова sendmsg().

Если вы беспокоитесь о производительности, sendmsg() не даст серьезного выигрыша в случае передачи только одного iovec. Однако если вы объединяете буферы в пользовательском пространстве, это потенциально может принести некоторый выигрыш, хотя он и будет достигнут за счет переносимости кода.

При отправке данных поле адреса msg_name игнорируется для сокетов, ориентированных на соединение. Но в некоторых ОС в этом случае передача может завершиться с ошибкой EISCONN, поэтому лучше установить адрес в nullptr.

Для дейтаграммных сокетов, если был вызван connect(), этот адрес переопределит указанный ранее.

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

Рис. 9.1. Структура iovec

Внимание! Если поле адреса задано, поле msg_namelen перед вызовом всегда должно быть равным размеру адреса как при отправке, так и при приеме данных.

Поле msg_iov содержит 0 или более буферов с отправляемыми данными либо заполняемых принимаемыми данными. Поле msg_iovlen должно быть установлено равным числу буферов в массиве.

Максимальное число буферов в некоторых ОС можно узнать, используя функцию sysconf(), описываемую в главе 13.

Вспомогательные данные передаются в поле msg_control, длина которого задается в msg_controllen.

Поле msg_flags действительно лишь в случае получения данных, sendmsg() его игнорирует. Устанавливается оно при успешном завершении recvmsg(). Содержимое поля — флаги, объединенные через побитовое ИЛИ:

• MSG_EOR — принят конец записи. Этот флаг сбрасывается, если возвращаемые данные не завершают логическую запись. Флаг должен быть поддержан конкретным протоколом. Обычно используется с сокетами типа SOCK_SEQPACKET. TCP, например, его не поддерживает, так как это потоковый протокол.

• MSG_TRUNC — завершающая часть дейтаграммы была отброшена из-за того, что дейтаграмма была больше предоставленного буфера. То есть ядро имеет больше данных для возврата, чем процесс выделил в сумме всех буферов, иначе сумма всех iov_len меньше размера данных. Этот флаг устанавливается, только если были усечены обычные данные.

• MSG_CTRUNC — часть управляющих данных была отброшена из-за нехватки места для них в буфере. У ядра есть больше вспомогательных данных для возврата, чем размер буфера, выделенный процессом, — msg_controllen меньше размера вспомогательных данных.

• MSG_OOB — были получены внеполосные данные. Этот флаг никогда не возвращается для внеполосных данных TCP. Другие семейства протоколов, например протоколы OSI, могут его вернуть.

• MSG_ERRQUEUE — указывает, что вместо данных была получена расширенная ошибка из очереди ошибок сокета.

• MSG_BCAST — дейтаграмма была получена как широковещательная передача канального уровня или на широковещательный IP-адрес. Хороший способ определить, была ли UDP-дейтаграмма отправлена на широковещательный адрес.

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

• MSG_NOTIFICATON — возвращается получателям SCTP, чтобы указать, что сообщение прочитано.

Данные из каждой области хранения, указанной msg_iov, отправляются по очереди.

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

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

Поэтому если в буфере сокета нет места для хранения передаваемого сообщения, а в файловом дескрипторе сокета не установлено значение O_NONBLOCK, функция sendmsg() блокируется до тех пор, пока не освободится место.

Если же в передающем сокете нет места для хранения сообщения, которое должно быть передано, и дескриптор файла сокета имеет установленный O_NONBLOCK, функция sendmsg() не будет работать и вернет ошибку. В errno будет записано EMSGSIZE.

В Python существует несколько методов класса socket.socket, выполняющих то же, что и описанные выше функции:

_CMSG: TypeAlias = tuple[int, int, bytes]

_CMSGArg: TypeAlias = tuple[int, int, ReadableBuffer]

if sys.platform != "win32":

# Функция сама выделит буфер.

def recvmsg(self, bufsize: int, ancbufsize: int, flags: int) ->

tuple[bytes, list[_CMSG], int, Any]

# Принять данные в предварительно выделенный буфер.

def recvmsg_into(self, buffers: Iterable[WriteableBuffer],

ancbufsize: int, flags: int) ->

tuple[int, list[_CMSG], int, Any]

def sendmsg(

self,

buffers: Iterable[typing.Buffer],

ancdata: Iterable[_CMSGArg],

flags: int,

address: _Address,

) -> int

if sys.platform == "linux":

# Требуется для работы с криптографией в ядре Linux.

def sendmsg_afalg(

self, msg: Iterable[typing.Buffer], *, op: int,

iv: Any, assoclen: int, flags: int

) -> int

Видим, что эти методы не определены для ОС Windows. Но при необходимости из Python можно напрямую использовать упомянутые выше функции WSASendMsg() и WSARecvMsg().

В CPython данные методы на момент выхода книги могут быть реализованы и в ОС Windows.

Типы буфера для recvmsg_into() такие же, как и для recv_into(). Разница в том, что в рассматриваемом случае передается список буферов.

Параметр ancbufsize, задающий размер буфера вспомогательных данных, является обязательным. Получить его можно, используя CMSG-функции. О них мы поговорим далее. Остальные аргументы, в принципе, соответствуют аргументам функций C API.

Метод socket.sendmsg_afalg() используется для работы с криптографическими функциями ядра Linux через сокеты семейства AF_ALG. Он не имеет отношения к сети и в книге не рассматривается.

Обмен вспомогательными данными

Внимание! Желательно всегда передавать функции хотя бы один буфер размером хотя бы в один байт, то есть один символ, под обычные данные, даже если принимаются только вспомогательные данные. Иначе в некоторых ОС, например в определенных версиях MacOS X, можно получить ошибку.

Как было показано выше, управляющие данные можно отправлять и получать, используя поля msg_control и msg_controllen структуры msghdr.

Поле msg_control указывает на буфер для вспомогательных данных. Когда вызывается recvmsg() или sendmsg(), msg_controllen должен содержать длину буфера msg_control, включая заголовок в байтах.

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

Внимание! Если пространство, выделенное для приема входящих служебных данных, слишком мало, служебные данные усекаются до количества заголовков, которые уместятся в предоставленном буфере, и в msg.msg_flags устанавливается флаг MSG_CTRUNC.

Сообщения имеют вид:

struct cmsghdr

{

// Количество байтов данных, включая заголовок.

size_t cmsg_len;

// Исходный протокол.

int cmsg_level;

// Тип, зависящий от протокола.

int cmsg_type;

// После заголовка идут данные:

// unsigned char cmsg_data[];

};

В буфере находится сразу несколько таких сообщений, предваряемых заголовками, как показано на рис. 9.2.

Видим, что заголовок дополняется «полем» pad, то есть нулями, для того чтобы данные были расположены по выровненному адресу. Для получения данных это надо учитывать.

Рис. 9.2. Вспомогательные данные

Чтобы не вычислять адрес самостоятельно, для работы со вспомогательными данными используются макросы, описанные в man 3 cmsg и man 3 cmsg_space:

#include <sys/socket.h>

// Движение по заголовкам данных.

cmsghdr *CMSG_FIRSTHDR(msghdr *msgh);

cmsghdr *CMSG_NXTHDR(msghdr *msgh, cmsghdr *cmsg);

// Определить выравнивание.

size_t CMSG_ALIGN(size_t length);

// Определить необходимое пространство в буфере для блока.

size_t CMSG_SPACE(size_t length);

// Определить длину элемента.

size_t CMSG_LEN(size_t length);

// Получить указатель на первый байт данных.

unsigned char *CMSG_DATA(struct cmsghdr *cmsg);

Не работайте напрямую с последовательностью структур cmsghdr. Всегда используйте макросы:

• CMSG_FIRSTHDR() — получить указатель на первый заголовок cmsghdr в буфере вспомогательных данных, связанном с переданной структурой msghdr. Если данных нет — возвращает нулевой указатель.

• CMSG_NXTHDR() — получить указатель на следующий заголовок cmsghdr. Макросу передается структура msghdr и структура cmsghdr, возвращенная CMSG_FIRSTHDR или предыдущим вызовом CMSG_NXTHDR. Если заголовков больше нет или в буфере недостаточно места, возвращает нулевой указатель.

• CMSG_ALIGN() — округлить длину сообщения в большую сторону, до выровненного.

• CMSG_SPACE() — получить размер буфера, который займет элемент, принятый через recvmsg(), включая дополнение в конце до выравнивания и заголовок. Размер буфера, который требуется для приема нескольких элементов, будет равен сумме значений, полученных из нескольких вызовов данного макроса.

• CMSG_LEN() — получить выровненную длину элемента вспомогательных данных для сохранения в поле cmsg_len структуры cmsghdr. Значение будет всегда меньше или равным CMSG_SPACE() для одинакового аргумента. Его аргумент — длина данных.

• CMSG_DATA() — получить указатель на начало данных, идущих после заголовка cmsghdr. Данные по указателю не выровнены, и поэтому не на всех платформах может быть обеспечен доступ к каждому полю структуры. Приложения не должны приводить его к типу указателя, соответствующему типу полезной нагрузки. Следует копировать данные, например, через std::copy() или memcpy(), в объект либо из него.

В разных ОС макросы CMSG_SPACE и CMSG_LEN могут значительно различаться. Поэтому не стоит заменять один другим.

Ниже приведен упрощенный пример для Linux из /usr/include/bits/socket.h:

#define CMSG_DATA(cmsg) ((unsigned char *)((struct cmsghdr *)(cmsg) + 1))

#define CMSG_NXTHDR(mhdr, cmsg) __cmsg_nxthdr(mhdr, cmsg)

#define CMSG_FIRSTHDR(mhdr)                                           \

((size_t)(mhdr)->msg_controllen >= sizeof(struct cmsghdr)         \

? (struct cmsghdr *) (mhdr)->msg_control : (struct cmsghdr *) 0)

#define CMSG_ALIGN(len) (((len) + sizeof(size_t) — 1) \

& (size_t) ~(sizeof(size_t) — 1))

#define CMSG_SPACE(len) (CMSG_ALIGN(len) \

+ CMSG_ALIGN(sizeof(struct cmsghdr)))

#define CMSG_LEN(len) (CMSG_ALIGN(sizeof(struct cmsghdr)) + (len))

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

Внимание! Для получения данных скопируйте их в предварительно выделенный объект. Не пытайтесь обращаться к ним по возвращенному указателю! На SPARC, PowerPC, DEC Alpha прямой доступ без копирования не будет работать. Скорее всего, на этих машинах будут работать ОС AIX и Solaris. Но в случае Linux вы также можете получить SIGBUS и аварийное завершение приложения.

В Python часть вышеуказанных макросов представлена следующими функциями модуля socket:

def CMSG_LEN(length: int) -> int

def CMSG_SPACE(length: int) -> int

Они могут вызвать исключение OverflowError, если значение длины выходит за размеры буфера. Остальные функции в Python не требуются: работа со вспомогательными данными реализована достаточно прозрачно.

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

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

Код хорошо иллюстрирует вызов recvmsg(), использование макросов и обход вспомогательных данных с целью поиска необходимого сообщения.

Создадим сокет, привяжем к адресу и установим на сокет требуемую опцию:

const socket_wrapper::SocketWrapper sock_wrap;

try

{

assert(argv[1]);

// Создать UDP-сокет и привязать адрес.

auto servinfo = socket_wrapper::get_serv_info(argv[1], SOCK_DGRAM);

socket_wrapper::Socket sock = {servinfo->ai_family, servinfo->ai_socktype,

servinfo->ai_protocol};

if (bind(sock, servinfo->ai_addr, servinfo->ai_addrlen) < 0)

{

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

std::system_category(), "bind");

}

// Значение опции IPRECV_TTL — включено.

int recv_ttl = 1;

// Включить опцию IP_RECVTTL.

if (setsockopt(sock, IPPROTO_IP, IP_RECVTTL, &recv_ttl,

sizeof(recv_ttl)) != 0)

{

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

std::system_category(), "Set IP_RECVTTL");

}

Размер выделенного буфера — 4 байта, поскольку в setsockopt() для передачи флагов, как правило, используется int. Поэтому безопасно выделять буфер именно такого размера.

Максимальный размер буфера является конфигурируемым. В Linux он задается в /proc/sys/net/core/optmem_max. В современных версиях ядра по умолчанию он равен примерно 20 Кбайт.

Определим размер буфера для обычных и вспомогательных данных:

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

std::array<char, 255> data_buff;

// Буфер для вспомогательных данных.

std::array<char, CMSG_SPACE(sizeof(uint32_t))> ancil_data_buff;

// Буфер для основных данных пока один: указатель на выделенный буфер

// и его размер в байтах.

iovec iov[1] = { { &data_buff[0], data_buff.size() } };

// Необходимо заполнить структуру msgh.

msghdr msgh =

{

.msg_name = nullptr, .msg_namelen = 0,

// Буферы для основных данных и количество этих буферов.

.msg_iov = iov, .msg_iovlen = 1,

// Буфер вспомогательных данных и его размер в байтах.

.msg_control = &ancil_data_buff[0],

.msg_controllen = ancil_data_buff.size(),

.msg_flags = 0

};

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

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

for (ssize_t n = recvmsg(sock, &msgh, 0); n; n = recvmsg(sock, &msgh, 0))

{

if (n < 0)

{

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

std::system_category(), "recvmsg");

}

std::cout

<< n

<< " bytes was read: "

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

<< std::endl;

// Указатель на управляющие данные.

cmsghdr *cmsg = nullptr;

// Идти по элементам управляющих данных.

for (cmsg = CMSG_FIRSTHDR(&msgh); cmsg != nullptr;

cmsg = CMSG_NXTHDR(&msgh, cmsg))

{

// Как было сказано выше, установка опции IP_RECVTTL

// будет приводить к отправке сообщения

// IP_TTL, передаваемого со вспомогательными данными.

if (IPPROTO_IP == cmsg->cmsg_level && IP_TTL == cmsg->cmsg_type)

{

uint32_t received_ttl = 0;

// Помним, что сообщение необходимо скопировать.

std::copy(CMSG_DATA(cmsg),

CMSG_DATA(cmsg) + sizeof(received_ttl),

&received_ttl);

std::cout

<< "IP_RECVTTL was received: " << received_ttl

<< std::endl;

break;

}

}

Макрос CMSG_FIRSTHDR() инициализирует структуру cmsg в цикле первым управляющим сообщением и CMSG_NXTHDR() для получения всех последующих.

Указатель на данные получаем, используя макрос CMSG_DATA(), после чего копируем данные, как было сказано выше. Только после этого к ним можно осуществлять доступ.

Если нужных элементов в списке не оказалось, мы увидим, что cmsg имеет нулевое значение:

if (nullptr == cmsg)

{

// Цикл в конце списка, и CMSG_NXTHDR() вернул nullptr,

// а сообщения IP_TTL не было.

// Значит, произошла ошибка.

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

std::system_category(),

"IP_RECVTTL receiving");

}

break;

}

}

Запустим Netcat и отправим строку на сервер:

➭ nc -u localhost 12345

RECV_TTL testing!

В результате увидим, что пришло значение TTL, равное 64:

➭ build/bin/b01-ch09-ancil-data-recv 12345

18 bytes was read: RECV_TTL testing!

IP_RECVTTL was received: 64

Если отключить опцию IP_RECVTTL, сообщение приходить не будет.

Стоит помнить, что перед каждым вызовом recvmsg() необходимо правильно инициализировать поля структуры, ей передаваемой.

Аналогичный код на Python:

import socket

import sys

# Не все константы есть в модуле socket, некоторые приходится объявлять.

IP_RECVTTL = 12

if len(sys.argv) < 2:

print(f'{sys.argv[0]} <port>')

sys.exit(1)

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:

sock.bind(('', int(sys.argv[1])))

# Установить опцию IP_RECVTTL.

sock.setsockopt(socket.IPPROTO_IP, IP_RECVTTL, 1)

ttl = 0

# Поле TTL 32-битное, то есть имеет размер 4 байта.

ttl_size = 4

# 255 — произвольное значение буфера для обычных данных.

# Минимальный размер буфера для вспомогательных данных всегда нужно

# рассчитывать через CMSG_LEN.

# В Python нет структуры msghdr, поэтому вернется кортеж.

msg, ancdata, flags, addr = sock.recvmsg(255, socket.CMSG_LEN(ttl_size))

print(f'{len(msg)} bytes was read: {msg.decode()}')

# Вспомогательные данные представлены списком кортежей.

for cmsg_level, cmsg_type, cmsg_data in ancdata:

if cmsg_level == socket.IPPROTO_IP and socket.IP_TTL == cmsg_type:

# То, что в C API делает макрос CMSG_DATA().

ttl_pointer = cmsg_data[:len(cmsg_data) -

(len(cmsg_data) % ttl_size)]

# Получить целое значение из байтов.

# Порядок байтов указан как LE, поскольку значения в полях

# дейтаграммы представлены в сетевом порядке.

ttl = int.from_bytes(ttl_pointer, byteorder='little')

print(f'IP_RECVTTL was received: {ttl}')

break

Прием вспомогательных данных на потоковых сокетах «разрывает поток». То есть когда вспомогательные и обычные данные были отправлены за несколько вызовов sendmsg(), даже если recvmsg() сможет принять их в буфер сразу, он все равно будет возвращать управление каждый раз после приема вспомогательных данных, переданных в отдельном вызове sendmsg(). Данные, переданные вместе со вспомогательными, могут быть записаны в буфер сразу.

Создание и отправка вспомогательных данных

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

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

Иногда пользователю требуется не только принимать вспомогательные данные от сетевой подсистемы, но и отправлять самому. В главе 8 был рассмотрен пример с CRIU. Однако классический сценарий — отправка дескрипторов в процесс, связанный через сокет PF_UNIX. Мы рассмотрим его ниже, в разделе об управляющих данных Unix-сокетов.

Отправка выполняется через функцию sendmsg(), и при работе с ней важно правильно использовать CMSG-макросы.

Для получения значения длины отправляемых данных используется макрос CMSG_LEN(), а для установки значения поля msg_controllen — макрос CMSG_SPACE():

// Некий дескриптор будет играть роль отправляемых данных.

int fd = open(...);

// Заголовок блока сообщений.

msghdr msg = { 0 };

// Буфер с "данными".

std::vector<char> buf(1);

// Создание буфера для вспомогательных данных.

// Размер сообщения учитывает дополнение.

// Поэтому используется CMSG_SPACE.

std::vector<char> ancil_buf(CMSG_SPACE(sizeof(fd)));

...

msg.msg_control = &ancil_buf[0];

// Установка размера:

msg.msg_controllen = CMSG_SPACE(sizeof(fd));

// Или так:

msg.msg_controllen = ancil_buf.size();

// Заголовок первого сообщения.

cmsghdr * cmsg = CMSG_FIRSTHDR(&msg);

// Установить различные параметры.

cmsg->cmsg_level = SOL_SOCKET;

cmsg->cmsg_type = SCM_RIGHTS;

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

cmsg->cmsg_len = CMSG_LEN(sizeof(fd));

// Сюда будут записаны данные.

*reinterpret_cast<int *>(CMSG_DATA(cmsg)) = fd;

// Отправить данные.

if (sendmsg(socket, &msg, 0) < 0)

...

Вспомогательные данные Internet-сокетов

Для приема следующих сообщений в cmsg_level необходимо установить уровень IPPROTO_IP:

• IP_ORIGDSTADDR — исходный адрес получателя дейтаграммы. Сообщение содержит структуру адреса sockaddr_in. Отправку включает опция IP_RECVORIGDSTADDR.

• IP_PKTINFO — содержит информацию о входящем пакете. Одноименная опция включает отправку данного сообщения. Применима только для сокетов, ориентированных на дейтаграммы. Сообщение содержит структуру in_pktinfo, описанную ниже. Отправка этого сообщения включается одноименной опцией.

• IP_RECVERR — в случае ошибки сокета можно получить расширенную информацию, вызвав recvmsg() с установленным флагом MSG_ERRQUEUE. Будет возвращен массив ошибок из структур sock_extended_err.

• SCM_SECURITY — контекст безопасности SELinux однорангового сокета. Данные представляют собой строку с завершающим нулем, содержащую контекст безопасности. Получатель должен выделить для этих данных не менее NAME_MAX байт в части данных вспомогательного сообщения. Отправку включает опция SO_PASSSEC.

• IP_TOS — содержит байт с полем «тип обслуживания» из заголовка IP-пакета. Будет принят через recvmsg(), если на сокете включена опция IP_RECVTOS. Значение TOS может быть установлено через опцию IP_TOS.

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

struct in_pktinfo

{

// Индекс сетевого интерфейса.

unsigned int ipi_ifindex;

// Локальный адрес.

struct in_addr ipi_spec_dst;

// Адрес назначения из заголовка.

struct in_addr ipi_addr;

};

Структура ошибки:

#define SO_EE_ORIGIN_NONE    0

#define SO_EE_ORIGIN_LOCAL   1

#define SO_EE_ORIGIN_ICMP    2

#define SO_EE_ORIGIN_ICMP6   3

struct sock_extended_err

{

// Номер ошибки в очереди.

uint32_t ee_errno;

// Код происхождения ошибки: SO_EE_ORIGIN_ICMP или SO_EE_ORIGIN_LOCAL.

uint8_t ee_origin;

// Тип.

uint8_t ee_type;

// Код.

uint8_t ee_code;

// Нулевое заполнение.

uint8_t ee_pad;

// Дополнительная информация.

uint32_t ee_info;

// Прочие данные.

uint32_t ee_data;

// Дальше могут следовать еще данные.

};

// Указатель на адрес узла.

struct sockaddr *SO_EE_OFFENDER(struct sock_extended_err *);

Значения полей, кроме ee_errno и ee_origin, зависят от протокола:

• ee_origin имеет значение:

• SO_EE_ORIGIN_ICMP для ошибок, полученных в виде пакета ICMP;

• SO_EE_ORIGIN_LOCAL для локально сгенерированных ошибок.

• ee_type и ee_code — устанавливаются из полей типа и кода заголовка ICMP.

• ee_info — содержит обнаруженный MTU для ошибок EMSGSIZE.

• Макрос SO_EE_OFFENDER возвращает указатель на адрес узла, вызвавшего ошибку, и этот адрес — структура sockaddr_in. Поле sin_family адреса имеет значение AF_UNSPEC, когда источник неизвестен.

Когда ошибка произошла в сети, все параметры IP, такие как IP_OPTIONS, IP_TTL, разрешенные для сокета и содержащиеся в пакете ошибки, передаются в качестве управляющих сообщений. При этом полезная нагрузка пакета все равно будет получена.

TCP не имеет очереди ошибок, поэтому сообщение MSG_ERRQUEUE нельзя использовать для сокетов SOCK_STREAM. Опция IP_RECVERR действительна для TCP, но все ошибки возвращаются в errno после вызова функций или при получении значения опции сокета SO_ERROR.

Для raw-сокетов IP_RECVERR разрешает передачу всех полученных ошибок ICMP в приложение, в противном случае сообщения об ошибках передаются только для подключенных сокетов.

Вспомогательные данные Unix-сокетов

Внимание! Если вы получаете любые управляющие данные из Unix-сокетов через recvmsg(), всегда обрабатывайте сообщение SCM_RIGHTS, даже если не используете его данные, и закрывайте все отправленные дескрипторы. Иначе атакующий субъект может забить таблицу дескрипторов вашего процесса.

Как и опции, вспомогательные типы сообщений имеют уровень SOL_SOCKET в cmsg_level по историческим причинам.

Рассмотрим эти сообщения:

• SCM_RIGHTS — отправить или получить набор дескрипторов открытых файлов от другого процесса. Если буфер, используемый для приема вспомогательных данных, содержащих файловые дескрипторы, слишком мал, вспомогательные данные усекаются или отбрасываются, а лишние файловые дескрипторы автоматически закрываются. Если процесс превысит лимит по числу открытых дескрипторов — RLIMIT_NOFILE, лишние дескрипторы будут закрыты. В ядре есть константа SCM_MAX_FD со значением примерно 255, определяющая максимальное ограничение на количество файловых дескрипторов в массиве. Попытка отправить массив больше этого предела вызовет ошибку. Пример для этого сообщения был приведен выше.

• SCM_CREDENTIALS — отправить или получить учетные данные UNIX. Можно использовать для аутентификации: данные, указанные отправителем, проверяются ядром. Учетные данные передаются как структура ucred, определенная в sys/socket.h. Чтобы получить сообщение, на сокете должна быть включена опция SO_PASSCRED.

• SCM_SECURITY — получить контекст безопасности SELinux однорангового сокета. Описано в опциях Internet-сокетов.

Префикс "SCM_" означает Socket Control Message.

Структура для сообщения SCM_CREDENTIALS:

struct ucred

{

// Идентификатор процесса-отправителя.

pid_t pid;

// Идентификатор пользователя процесса-отправителя.

uid_t uid;

// Идентификатор группы процесса-отправителя.

gid_t gid;

};

Отправка и получение файловых дескрипторов

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

Дескриптор, в том числе сокета, представляет собой индекс в таблице, содержащей ссылки на объекты ядра и некоторые флаги. У каждого процесса своя таблица дескрипторов и свой набор дескрипторов, которые имеют значение только в его границах, что видно на рис. 9.3.

Рис. 9.3. Таблицы дескрипторов и файлов

Можно создавать новый дескриптор одного и того же объекта, управлять наследованием дескрипторов и передавать дескрипторы из процесса в процесс, работающий в том же экземпляре ОС. В Python, например, для этого существуют методы сокета:

def socket.dup() -> socket.socket

def socket.get_inheritable() -> bool

def socket.set_inheritable(inheritable: bool)

Метод dup() дублирует сокет — дубликат не будет наследоваться в потомках. Оставшиеся два метода служат для получения и установки флага наследования.

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

В Python для обмена дескрипторами реализованы функции в модуле socket:

# Отправить дескрипторы.

def send_fds(sock, buffers, fds[, flags[, address]]) -> int

# Принять дескрипторы.

def recv_fds(sock, bufsize, maxfds[, flags]) -> \

(data, file_descriptors, msg_flags, address)

Посмотрим на их код:

def send_fds(sock, buffers, fds, flags=0, address=None):

""" send_fds(sock, buffers, fds[, flags[, address]]) -> integer

Отправляет список файловых дескрипторов поверх сокета AF_UNIX.

"""

# Дескрипторы — массив целых чисел.

return sock.sendmsg(buffers, [(_socket.SOL_SOCKET, _socket.SCM_RIGHTS,

array.array("i", fds))])

Для отправки дескрипторов используется операция, инициируемая сообщением SCM_RIGHTS.

def recv_fds(sock, bufsize, maxfds, flags=0):

""" recv_fds(sock, bufsize, maxfds[, flags]) ->

(data, list of file descriptors, msg_flags, address)

Принимает до maxfds файловых дескрипторов, возвращает данные и список

дескрипторов.

"""

# Массив целых чисел.

fds = array.array("i")

msg, ancdata, flags, addr = sock.recvmsg(bufsize,

_socket.CMSG_LEN(maxfds *

fds.itemsize))

for cmsg_level, cmsg_type, cmsg_data in ancdata:

if (cmsg_level == _socket.SOL_SOCKET and

cmsg_type == _socket.SCM_RIGHTS):

# Создание массива целых чисел из массива байтов.

fds.frombytes(cmsg_data[:len(cmsg_data) -

(len(cmsg_data) % fds.itemsize)])

return msg, list(fds), flags, addr

В C++ процесс отправки данных чуть сложнее.

Сначала необходимо подготовить данные, как показано на рис. 9.4:

1. Создать буфер, размер которого равен сумме CMSG_SPACE() для всех управляющих сообщений, которые необходимо отправить.

2. Поле msghdr.msg_controllen нужно установить равным размеру буфера.

3. Используя макросы CMSG_FIRSTHDR() и CMSG_NXTHDR(), пройти по всем заголовкам cmsg, инициализируя:

• поля cmsg_level и cmsg_type;

• поле cmsg_len, используя для получения его значения макрос CMSG_LEN() от длины отправляемых данных;

• другие поля заголовка cmsghdr и данные возможно инициализировать через макрос CMSG_DATA().

Рис. 9.4. Подготовка буфера к отправке

Затем подготовленный буфер нужно передать функции sendmsg(). Нам потребуется вспомогательная функция, которая вернет по номеру дескриптора путь. Реализуем ее через обращение к /proc:

std::filesystem::path get_path_from_fd(int fd)

{

char filename[PATH_MAX];

// Все дескрипторы процесса есть в /proc, о которой будет рассказано

// в главе 13.

const std::string link = "/proc/self/fd/" + std::to_string(fd);

// Дескриптор в /proc — симлинк, который ссылается на реальный файл

// или указывает на специальный, однако это не относится

//к нашему сценарию.

// Читая симлинк, получаем имя файла.

if (readlink(link.c_str(), filename, sizeof(filename)) < 0)

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

return std::filesystem::path(filename);

}

О /proc будет рассказано подробнее в главе 13.

Теперь реализуем отправку дескриптора. Отправим только одиночный дескриптор, но массив дескрипторов отправляется аналогично:

void send_descriptors(int socket, int fd)

{

msghdr msg = {};

// Буфер с "данными".

std::array<char, 1> buf;

// Создание буфера для вспомогательных данных.

std::array<char, CMSG_SPACE(sizeof(fd))> ancil_buf;

iovec io = { .iov_base = &buf[0], .iov_len = buf.size() };

msg.msg_iov = &io;

msg.msg_iovlen = 1;

msg.msg_control = &ancil_buf[0];

msg.msg_controllen = ancil_buf.size();

cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);

cmsg->cmsg_level = SOL_SOCKET;

cmsg->cmsg_type = SCM_RIGHTS;

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

cmsg->cmsg_len = CMSG_LEN(sizeof(fd));

// Он будет записан сюда.

*reinterpret_cast<int *>(CMSG_DATA(cmsg)) = fd;

// Отправка.

if (sendmsg(socket, &msg, 0) < 0)

throw std::system_error(errno, std::generic_category(), "sendmsg");

}

Рассмотрим функцию для приема дескрипторов. Она принимает данные и управляющую информацию через recvmsg():

int receive_descriptors(int socket)

{

msghdr msgh = {};

std::array<char, 255> buffer;

iovec io = { .iov_base = &buffer[0], .iov_len = buffer.size() };

msgh.msg_iov = &io;

msgh.msg_iovlen = 1;

std::array<char, buffer_size> c_buffer;

msgh.msg_control = &c_buffer[0];

msgh.msg_controllen = c_buffer.size();

// Принять сообщение, которое содержит обычные и вспомогательные данные.

if (recvmsg(socket, &msgh, 0) < 0)

throw std::system_error(errno, std::generic_category(), "recvmsg");

int received_descriptor = -1;

Обрабатывает вспомогательные данные в цикле:

// Обработка вспомогательных данных.

for (cmsghdr *cmsg = CMSG_FIRSTHDR(&msgh); cmsg != nullptr;

cmsg = CMSG_NXTHDR(&msgh, cmsg))

{

if (SOL_SOCKET == cmsg->cmsg_level && SCM_RIGHTS == cmsg->cmsg_type)

{

if (received_descriptor < 0)

{

// Помним, что безопаснее выполнить копирование.

std::copy(CMSG_DATA(cmsg), CMSG_DATA(cmsg) +

sizeof(received_descriptor),

&received_descriptor);

std::cout

<< "Descriptor was received: " << received_descriptor

<< std::endl;

}

else

{

// Если пришли лишние дескрипторы, закрыть их.

int fd;

std::copy(CMSG_DATA(cmsg), CMSG_DATA(cmsg) + sizeof(fd), &fd);

close(fd);

}

}

}

if (-1 == received_descriptor)

{

throw std::logic_error("Descriptor receiving error");

}

return received_descriptor;

}

Процесс-родитель, к примеру, создает файл для чтения и записи, после чего отправляет его дескриптор потомку:

void parent(int sock)

{

std::cout << "Parent started" << std::endl;

// Пусть это будет временный файл в /tmp.

char f_tmpl[] = "/tmp/descr_send_exampleXXXXXX";

// Функция возвращает дескриптор открытого временного файла.

int file_fd = mkstemp(f_tmpl);

if (file_fd < 0)

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

"Parent opening local file");

// По дескриптору получим путь к файлу.

const std::filesystem::path local_file(

std::move(get_path_from_fd(file_fd)));

std::cout

<< "Parent opened file with descriptor = " << file_fd

<< " [" << local_file << "]"

<< std::endl;

// Отправим этот дескриптор потомку.

send_descriptors(sock, file_fd);

// Сразу закроем файл и удалим. В реальном приложении с ним

// будет выполняться какая-то работа.

close(file_fd);

std::filesystem::remove(local_file);

std::cout << "Parent exited" << std::endl;

}

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

void child(int sock)

{

std::cout << "Child" << std::endl;

// Получить дескриптор.

int fd = receive_descriptors(sock);

std::cout

<< "Child received descriptor = " << fd

<< " [" << get_path_from_fd(fd) << "]"

<< std::endl;

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

// был открыт в этом процессе.

std::array<char, 256> buffer;

ssize_t nbytes;

std::cout << "Reading from a file in the child..." << std::endl;

for (ssize_t nbytes = read(fd, buffer.data(), buffer.size()); nbytes > 0;)

{

write(1, buffer.data(), nbytes);

}

close(fd);

std::cout << "Child exited" << std::endl;

}

В функции main() создается пара связанных Unix-сокетов и два процесса. Лишние дескрипторы закрываются, так как родитель только передает, а потомок только читает:

int main(int argc, char **argv)

{

socket_wrapper::SocketWrapper sock_wrap;

try

{

// Создать пару сокетов.

int sp[2];

if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sp) != 0)

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

std::generic_category(),

"socketpair");

// Создать потомка.

int pid = fork();

if (pid > 0)

{

// Выполняется процесс-родитель.

close(sp[1]);

parent(sp[0]);

}

else

{

// Выполняется потомок.

close(sp[0]);

child(sp[1]);

}

}

catch (const std::exception& e)

{

std::cerr << e.what() << std::endl;

return EXIT_FAILURE;

}

catch (...)

{

std::cerr << "Unknown exception!" << std::endl;

return EXIT_FAILURE;

}

return EXIT_SUCCESS;

}

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

➭ build/bin/b01-ch09-send-recv-descriptors

Parent started

Child

Parent opened file with descriptor = 4 ["/tmp/descr_send_exampleZ0KEzy"]

Parent exited

Descriptor was received: 3

Child received descriptor = 3 [/tmp/descr_send_exampleZ0KEzy (deleted)]

Reading from a file in the child...

Child exited

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

Также видим, что у потомка в названии файла присутствует слово «(deleted)». Это говорит о том, что файл был удален. И действительно, мы его удалили в родительском процессе. Но данный код выполнялся под управлением Linux, и эту ситуацию можно считать нормальной: «удаленные» файлы не будут видны в файловой системе. Однако физически они будут присутствовать, если хотя бы один процесс имеет связанный дескриптор.

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

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

Резюме

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

В Unix-подобных системах для обмена вспомогательными данными используются функции sendmsg() и recvmsg(). В ОС Windows — WSASendMsg() и WSARecvMsg(). Для обработки вспомогательных данных система предоставляет набор макросов.

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

В Linux обычно механизм вспомогательных данных используется для передачи расширенных ошибок и опций IP. А для сокетов домена AF_UNIX — файловых дескрипторов. Часто управляющие сообщения отправляются при изменении опций сокета.

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

1. Что такое вспомогательные данные в сетевом программировании?

2. Для чего ядро ОС проверяет вспомогательные данные?

3. Можно ли использовать вспомогательные данные для передачи пользовательских данных или через них может быть передана только системная информация?

4. Какие функции используются для отправки и получения вспомогательных данных? Могут ли эти функции использоваться в любой ОС?

5. Каким образом вспомогательные данные связаны с опциями сокета?

6. Почему рекомендуется использовать макросы при работе со структурами cmsghdr?

7. В чем разница макросов CMSG_SPACE() и CMSG_LEN()?

8. Как создать буфер для отправки или приема вспомогательных данных?

9. Назовите пример вспомогательных данных сокета семейства AF_INET. Как получить эти данные?

10. Что случится, если места в буфере недостаточно для приема вспомогательных данных?

11. Могут ли вспомогательные данные отправляться без обычных данных или только вместе с ними?

12. Зачем для передачи файловых дескрипторов между процессами использовать вспомогательные данные?

13. Какие особенности необходимо учитывать при отправке массива дескрипторов через сокеты?

14. Когда нужно обрабатывать сообщение SCM_RIGHTS?

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

16. В примере о вспомогательных данных UNIX-сокетов мы отправили один дескриптор от родительского процесса к потомку. Доработайте пример таким образом, чтобы отправлялось несколько дескрипторов.

Глава 10. Управление сетевыми интерфейсами

Предлагаю перестать называть эту штуку «сетью ALTO ALOHA».

Во-первых, потому что она должна поддерживать любое количество разных типов станций — NOVA, PDP-11... Во-вторых, потому что организация начинает выглядеть намного привлекательнее, чем радиосеть ALOHA <…>.

Может быть, так: «ETHERnet». Еще предложения?

Боб Меткалф, MEMO, 1973

Введение

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

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

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

Когда речь идет о сетевом обмене, особенно с использованием стека TCP/IP, с точки зрения прикладного разработчика маршрутизация данных производится не через адаптеры, а через сетевые интерфейсы. Это абстракции, которые предоставляются устройствами. Они имеют адреса и являются точками входа и выхода данных из узла. Сетевые интерфейсы также имеют свои настройки.

На практике обычно одному адаптеру соответствует один интерфейс. Но может быть и так, что одно физическое устройство предоставляет несколько интерфейсов. Или, наоборот, один интерфейс работает поверх двух и более адаптеров для повышения отказоустойчивости или скорости.

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

Рис. 10.1. Устройства и интерфейсы

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

Отношение адаптеров и интерфейсов показано на рис. 10.1. Два адаптера — верхний с тремя физическими интерфейсами, нижний с одним — предоставляют в сумме четыре «логических интерфейса» — точки входа для стека протоколов.

Один интерфейс верхнего адаптера имеет связанный IP, второй, похоже, не используется, а третий входит в группу с интерфейсом второго адаптера. Это так называемый bonding. Два интерфейса образуют виртуальный интерфейс, а ОС распределяет данные, которые идут в этот интерфейс с адресом 10.10.1.2, по «реальным» интерфейсам, что используется для балансировки нагрузки и повышения надежности: если один из сетевых адаптеров откажет, данные все равно будут передаваться через второй.

Более широкие возможности предоставляют виртуальные интерфейсы TUN и TAP, рассматриваемые в главе 23.

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

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

Данную главу можно использовать в качестве справочника при дальнейшей работе с книгой.

Функция ioctl()

Системный вызов ioctl, показанный на рис. 10.2, дает возможность напрямую общаться с драйверами и подсистемами ядра. Его выполняет функция с одноименным названием.

Рис. 10.2. Ioctl в Linux

Она позволяет устанавливать параметры устройств, напрямую обращаясь к драйверу. Функция объявлена в файле sys/ioctl.h, но для ее использования нужно подключать отдельные заголовочные файлы для каждой группы параметров.

Вызов ioctl сильно отличается от параметра к параметру, а сами параметры различаются не только в разных ОС, но и в разных версиях одной и той же ОС.

В Linux их номенклатура и параметры определяются версией ядра и загруженными модулями.

Прототип функции:

int ioctl(int fd, unsigned long request, ...);

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

• fd — дескриптор сокета.

• request — код запроса, который зависит от устройства. Может иметь тип int или unsigned long в зависимости от реализации.

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

В случае неудачи данная функция возвращает –1, в случае успеха — обычно 0. Тем не менее она может возвращать любые неотрицательные значения от драйверов.

Структура ioctl

Код запроса имеет структуру, хотя и не стандартизованную.

В Linux данная структура описывается макросами из заголовочного файла asm/ioctl.h и представляет собой вызов макросов:

• _IO(тип_подсистемы, номер_устройства).

• {_IOR, _IOW, _IOWR}(тип, номер, размер_аргумента).

Например, ioctl SIOCATMARK, используя который работает одноименная функция, может быть определен так:

#define SIOCATMARK _IOR('s', 7, int)

Его идентификатор имеет значение 0x8905.

В PIP также существует пакет python-ioctl-opt, который позволяет создавать из параметров коды ioctl. Но это не кросс-платформенное решение. В книге оно не рассматривается, так как может быть полезно разве что при написании собственного модуля ядра.

Внимание! Установка параметров через ioctl может влиять на устройства и всю операционную систему. Вы можете неправильно сконфигурировать ОС или даже испортить устройство. Многие из вызовов имеют некоторые отличия в разных ОС. Перед тем как пользоваться определенным ioctl, прочитайте документацию, чтобы знать, как он работает в конкретной ОС.

Пример использования ioctl в Linux для установки флага O_ASYNC, включающего асинхронный ввод-вывод на сокете:

#include <sys/socket.h>

#include <sys/ioctl.h>

...

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

int async_enabled = 1;

if (-1 == ioctl(sock, FIOASYNC, &async_enabled))

{

perror("ioctl");

}

В ОС Windows вместо ioctl() есть несколько функций: ioctlsocket(), WSAIoctl(), DeviceIoControl(). О том, как с ними работать, будет рассказано в главе 18.

В стандартной библиотеке Python имеется модуль fcntl, в котором реализована функция ioctl():

import fcntl

@overload

def ioctl(fd: FileDescriptorLike, request: int, arg: int = 0,

mutate_flag: bool = True, /) -> int

def ioctl(fd: FileDescriptorLike, request: int, arg: bytes,

mutate_flag: bool = True, /) -> bytes

@overload

def ioctl(fd: FileDescriptorLike, request: int, arg: WriteableBuffer,

mutate_flag: Literal[False], /) -> bytes

@overload

def ioctl(fd: FileDescriptorLike, request: int, arg: Buffer,

mutate_flag: bool = True, /) -> Any

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

• fd — файловый дескриптор сокета.

• request — операция, выполняемая над дескриптором.

• arg — аргумент запроса.

• mutate_flag — если True, результаты, возвращаемые функции ядром, будут записаны в буфер, передаваемый в аргументе. Если флаг не установлен, буфер не будет изменен. Такое поведение необходимо для установки параметров устройств или ядра, без возврата результата.

Функция может быть использована в Unix-подобных системах. Чтобы воспользоваться ею на объекте класса socket.socket, необходимо получить дескриптор сокета. Это позволяет сделать метод socket.fileno():

def fileno(self) -> int

Для ОС Windows непосредственно в классе socket.socket реализован метод socket.ioctl():

if sys.platform == "win32":

def ioctl(self, control: int,

option: int | tuple[int, int, int] | bool) -> None

Его параметры:

• control — код опции. Поддерживаются коды SIO_RCVALL, SIO_KEEPALIVE_VALS, SIO_LOOPBACK_FAST_PATH. Эти опции описаны в главе 18.

• option — значение опции.

Метод выполняет функцию WSAIoctl(), и для ioctl на дескрипторе сокета в ОС Windows необходимо использовать его.

Внимание! В Python для Unix-подобных ОС и для Windows следует использовать разные функции!

Создадим серверный сокет:

import fcntl

import socket

import sys

import time

# Некоторые дополнительные коды ioctl содержатся в модуле termios.

# Но иногда может потребоваться определить код вручную.

from termios import FIONBIO

if len(sys.argv) < 3:

print(f'{sys.argv[0]} <port> <async_mode_flag>')

sys.exit(1)

port = int(sys.argv[1])

# Флаг асинхронного режима получают из командной строки.

async_mode = int(sys.argv[2].lower() in ['true', 't', '1', 'y'])

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:

# Желательно установить опцию, чтобы не получить исключение

# "OSError: [Errno 98] Address already in use"

# в случае повторного запуска.

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)

sock.bind(('', port))

sock.listen()

print(f'Async mode = {async_mode}')

Теперь проверим, существует ли метод ioctl() у класса socket.socket, и если это так, вызовем его, в противном случае будем использовать функцию ioctl() из модуля fcntl:

# Если у сокета имеется метод ioctl(), использовать его

# для установки значения через ioctlsocket().

if getattr(sock, 'ioctl', None):

print('Using socket.ioctl() method...')

# Установка опции для Windows, метод не возвращает результат.

sock.ioctl(FIONBIO, async_mode)

else:

# В противном случае попробовать функцию ioctl().

print('Using fcntl.ioctl() function...')

# Функция ioctl() принимает массив байтов.

buf = int(async_mode).to_bytes(length=4, byteorder='little')

# Вызов функции вернет результат.

io_result = fcntl.ioctl(sock.fileno(), FIONBIO, buf)

print(f'io_result = {io_result}')

# Проверить, установлен ли асинхронный режим.

while True:

try:

# В неблокирующем режиме, если в очереди нет подключений,

# accept() сгенерирует исключение BlockingIOError.

with sock.accept()[0] as c_sock:

break

except BlockingIOError:

print('Client not connected.')

time.sleep(1)

print('Client connected!')

В примере делается попытка установить асинхронный режим. Это одна из опций, которая поддерживается и ОС Windows, и большинством Unix-подобных ОС.

Некоторые коды ioctl доступны в модуле termios.

Функция fcntl.ioctl() обладает гораздо более широкими возможностями, чем метод сокета, например позволяет возвращать значения. Но если вы хотите реализовать подобное для Windows, используйте сторонние модули и прямой вызов упомянутых выше функций ioctlsocket() и WSAIoctl().

Например, из модуля win32file стороннего пакета pywin32, доступного на PIP, можно вызвать функцию DeviceIoControl(), описанную в главе 18.

Любую функцию библиотек, подключаемых к приложениям на C и C++, можно вызвать, используя модуль ctypes из Стандартной библиотеки Python.

Для проверки того, что установка опции работает, запустим пример в асинхронном режиме:

➭ src/book01/ch10/python/ioctl-async-test.py 12345 y

Async mode = 1

Using fcntl.ioctl() function...

io_result = b'\x01\x00\x00\x00'

Client not connected.

Client not connected.

Client connected!

Асинхронный режим мы рассмотрим в книге 2. При асинхронном запуске исполнение функции не блокируется.

Видим, что сервер работает в цикле, из которого его выводит подключение клиента:

➭ nc localhost 12345

В синхронном режиме ситуация несколько иная, вызов accept() блокируется:

➭ src/book01/ch10/python/ioctl-async-test.py 12345 n

Async mode = 0

Using fcntl.ioctl() function...

io_result = b'\x00\x00\x00\x00'

Client connected!

В ОС Windows код работает аналогично.

Вызовы к сетевым интерфейсам и модулям протоколов

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

Для взаимодействия с драйверами устройств почти весь интерфейс представлен системным вызовом ioctl. В Linux старые управляющие сетевые программы, такие как ifconfig, работают, используя вызовы ioctl.

Список параметров и операций ioctl в Unix-подобных системах доступен в man 7 netdevice. В ОС Windows есть отдельная страница MSDN с ioctl для WinSock.

Большинство сетевых протоколов имеют собственные параметры ioctl, задокументированные на man-страницах протоколов, обычно в разделе Ioctls, а также в man 7 socket.

Кроме того, некоторые устройства поддерживают ioctl, специфичные для них. Ниже мы рассмотрим общие вызовы к сетевым устройствам в Linux. В других Unix-подобных ОС, например в BSD-системах, данный список вызовов будет несколько отличаться, но большая их часть все равно присутствует.

Вызовы и параметры ioctl для ОС Windows будут рассмотрены в главе 18.

Стандартные вызовы

Linux поддерживает некоторые стандартные ioctl для настройки сетевых устройств. Их можно использовать на любом дескрипторе сокета независимо от семейства или типа. Большинство из них оперируют структурой ifreq:

#include <sys/ioctl.h>

#include <net/if.h>

// Длина названия интерфейса в структуре ниже, как правило — 16.

#define IFNAMSIZ 16

struct ifreq

{

// Название интерфейса.

char ifr_name[IFNAMSIZ];

// Остальные поля зависят от конкретной операции.

union

{

// Адрес, привязанный к интерфейсу.

struct sockaddr ifr_addr;

// Адрес назначения, широковещательный адрес и маска подсети.

struct sockaddr ifr_dstaddr;

struct sockaddr ifr_broadaddr;

struct sockaddr ifr_netmask;

// Аппаратный адрес.

struct sockaddr ifr_hwaddr;

// Флаги интерфейса.

short ifr_flags;

// Индекс сетевого интерфейса.

int ifr_ifindex;

// Метрика устройства.

int ifr_metric;

int ifr_mtu;

// Параметры областей памяти для работы с DMA.

struct ifmap ifr_map;

// Группа для объединяемых в bonding интерфейсов.

char ifr_slave[IFNAMSIZ];

// Новое имя для интерфейса.

char ifr_newname[IFNAMSIZ];

// Произвольные данные, которые будут переданы драйверу.

char *ifr_data;

};

};

Сокеты AF_INET6 — исключение, для работы с которым используется структура in6_ifreq:

struct in6_ifreq

{

struct in6_addr ifr6_addr;

u32 ifr6_prefixlen;

// Индекс сетевого интерфейса.

int ifr6_ifindex;

};

Обычно пользователь указывает, с каким устройством работать, устанавливая имя сетевого интерфейса в ifr_name либо его индекс для IPv6 в ifr6_ifindex.

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

В разных Unix-подобных ОС данная структура может несколько отличаться. Например, в NetBSD она по-другому определена и в ней отсутствуют поля ifr_hwaddr, ifr_slave, ifr_newname, а поле ifr_ifindex называется ifr_index.

Кроме того, в ней есть некоторые дополнительные поля.

Обычно данные структуры Unix-подобных ОС описаны в man 7 netdevice.

Для обращения к интерфейсу можно использовать имя, поскольку оно уникально в системе.

Если системный вызов ioctl является привилегированным, для его использования требуется, чтобы эффективный идентификатор процесса был нулевым или была установлена привилегия CAP_NET_ADMIN, иначе будет возвращена ошибка EPERM.

Получение номера интерфейса и перечисление интерфейсов

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

• SIOCGIFNAME — по индексу ifr_ifindex, вернуть имя интерфейса в ifr_name. Это единственный ioctl, который возвращает свой результат в ifr_name.

• SIOCGIFINDEX — получить индекс интерфейса в ifr_ifindex.

В качестве параметров они используют структуру ifreq, показанную выше.

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

...

extern "C"

{

#include <sys/ioctl.h>

#include <net/if.h>

}

...

ifreq ifr = {};

const socket_wrapper::Socket sock = {AF_INET, SOCK_DGRAM, IPPROTO_UDP};

std::string if_param(std::strlen(argv[1]), 0);

// Имя должно быть в нижнем регистре.

std::transform(argv[1], argv[1] + std::strlen(argv[1]),

if_param.begin(), [](unsigned char c)

{ return std::tolower(c); });

if ("-n" == if_param)

{

// Скопировать имя в структуру.

std::copy_n(argv[2],

std::min(strlen(argv[2]), static_cast<size_t>(IFNAMSIZ)),

static_cast<char*>(ifr.ifr_name));

// Вызвать ioctl.

if (ioctl(sock, SIOCGIFINDEX, &ifr) < 0)

{

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

"SIOCGIFINDEX");

}

std::cout

<< "Interface \"" << argv[2] << "\""

<< " index: " << ifr.ifr_ifindex

<< std::endl;

}

Здесь, наоборот, получим имя по индексу:

else if ("-i" == if_param)

{

const auto if_index = std::stoi(argv[2]);

// Задать индекс сетевого интерфейса.

ifr.ifr_ifindex = if_index;

// Вызвать ioctl.

if (ioctl(sock, SIOCGIFNAME, &ifr) < 0)

{

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

"SIOCGIFNAME");

}

std::cout

<< "interface " << if_index

<< " name: \"" << ifr.ifr_name << "\""

<< std::endl;

}

else

{

std::cerr

<< "Please use '-n' for name "

<< "or '-i' for index"

<< std::endl;

return EXIT_FAILURE;

}

Результат:

➭ build/bin/b01-ch10-if_nameindex_siocifname -n "wlo1"

Interface "wlo1" index: 3

➭ build/bin/b01-ch10-if_nameindex_siocifname -n "eno2"

Interface "eno2" index: 2

➭ build/bin/b01-ch10-if_nameindex_siocifname -i 1

interface 1 name: "lo"

➭ build/bin/b01-ch10-if_nameindex_siocifname -i 2

interface 2 name: "eno2"

Альтернативным вариантом работы с именами и индексами являются следующие функции:

#include <net/if.h>

// Получить индекс по имени.

unsigned int if_nametoindex(const char *ifname);

// Получить имя по индексу.

char *if_indextoname(unsigned int ifindex, char *ifname);

Буфер, на который указывает ifname, когда в него сохраняется имя интерфейса, должен иметь размер не менее IFNAMSIZ байт.

Перечисление интерфейсов реализуется через функцию if_nameindex(), объявленную в net/if.h:

#include <net/if.h>

struct if_nameindex *if_nameindex();

void if_freenameindex(struct if_nameindex *ptr);

Если функция завершается с ошибкой, она возвращает nullptr, в противном случае — динамически выделенный массив структур if_nameindex:

// Максимальная длина имени для интерфейса.

#define IF_NAMESIZE 16

struct if_nameindex

{

// 1, 2, ...

unsigned int if_index;

// null-завершенная строка имени: "eth0", ...

char *if_name;

};

После завершения работы с результатом этот массив необходимо освободить вызовом функции if_freenameindex().

Внимание! Функция и тип данных имеют одинаковое имя, поэтому ключевое слово struct для типа требуется указывать явно.

Посмотрим на примере:

extern "C"

{

#include <net/if.h>

}

int main()

{

// Выделить массив и записать туда структуры.

// Когда указатель выйдет из области видимости, будет вызван

// пользовательский обработчик удаления (deleter):

// функция if_freenameindex().

std::unique_ptr<struct if_nameindex, decltype(&if_freenameindex)>

if_ni(if_nameindex(), &if_freenameindex);

if (nullptr == if_ni)

{

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

"if_nameindex");

}

// Пройти по выделенному массиву.

for (auto i = if_ni.get(); !(0 == i->if_index && nullptr == i->if_name);

++i)

std::cout

<< i->if_index << ": "

<< i->if_name << "\n";

std::cout << std::endl;

return EXIT_SUCCESS;

}

Результат:

➭ build/bin/b01-ch10-if_nameindex_if_nameindex

1: lo

2: eno2

3: wlo1

4: docker0

19: tun0

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

#include <sys/types.h>

#include <ifaddrs.h>

int getifaddrs(ifaddrs **ifap);

void freeifaddrs(ifaddrs *ifa);

Функция getifaddrs() выделит память и в случае успеха вернет 0, а в случае неудачи –1.

В достаточно новых версиях Linux данная функция, как и все функции перечисления интерфейсов, показанные выше, работает не через ioctl, а через сокеты Netlink.

После завершения работы с данными память нужно освободить, используя функцию freeifaddrs().

Эти функции работают со структурой ifaddrs, которая определена в ifaddrs.h:

#include <ifaddrs.h>

struct ifaddrs

{

// Следующий элемент списка. Или nullptr, если это последний элемент.

struct ifaddrs *ifa_next;

// Название интерфейса, оканчивающееся нулем.

char *ifa_name;

// Флаги интерфейса, возвращенные ioctl SIOCGIFFLAGS.

unsigned int ifa_flags;

// Указатель на структуру, содержащую адрес интерфейса или nullptr.

struct sockaddr *ifa_addr;

// Указатель на структуру, содержащую сетевую маску, связанную с ifa_addr,

// если это применимо для семейства адресов.

struct sockaddr *ifa_netmask;

union

{

// Широковещательный адрес.

struct sockaddr *ifu_broadaddr;

// Адрес назначения "точка-точка".

struct sockaddr *ifu_dstaddr;

} ifa_ifu;

#define ifa_broadaddr ifa_ifu.ifu_broadaddr

#define ifa_dstaddr   ifa_ifu.ifu_dstaddr

// Буфер, содержащий данные, зависящие от семейства адресов.

void *ifa_data;

};

Структура представляет собой элемент списка; если элемент последний, поле ifa_next будет нулевым.

Поля ifa_addr и ifa_netmask могут содержать нулевой указатель, если у интерфейса не установлены адрес или маска.

Для определения формата структуры адреса следует обращаться к полю ifa_addr->sa_family.

В зависимости от того, установлен ли бит IFF_BROADCAST или IFF_POINTOPOINT в ifa_flags, в ifa_addr будет содержаться широковещательный адрес ifa_broadaddr или адрес «точка-точка» ifa_dstaddr.

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

Такое использование показано в примере ниже. Это поле может содержать нулевой указатель.

В некоторых ОС, например в IBM System i, поле ifa_data не используется.

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

Утилита strace также полезна, чтобы увидеть вызовы и возвращаемые Netlink-сокетом параметры, которые приложение читает, используя recvmsg().

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

Мы подробнее рассмотрим, как работать со strace, в главе 24.

Рассмотрим пример использования данного API.

Сначала реализуем функции для вывода адресов в читаемом виде. Выведем IPv4-адрес:

...

extern "C"

{

#include <net/if.h>

#include <ifaddrs.h>

#include <linux/if_link.h>

#include <linux/if_packet.h>

}

#include <socket_wrapper/socket_headers.h>

void print_address(const sockaddr *ia)

{

if (!ia)

{

std::cout << ": nullptr, but flag is set";

return;

}

// Буфер под адрес.

std::vector<char> addr_buf;

// Указатель на адрес.

const void *addr = nullptr;

if (AF_INET == ia->sa_family)

{

addr_buf.resize(INET_ADDRSTRLEN);

addr = &(reinterpret_cast<const sockaddr_in *>(ia))->sin_addr;

// Для отображения вызывается inet_ntop().

std::cout

<< " IPv4: "

<< inet_ntop(ia->sa_family, addr, &addr_buf[0], addr_buf.size());

}

Теперь выведем адреса IPv6:

else if (AF_INET6 == ia->sa_family)

{

addr_buf.resize(INET6_ADDRSTRLEN);

addr = &(reinterpret_cast<const sockaddr_in6 *>(ia))->sin6_addr;

std::cout

<< " IPv6: "

<< inet_ntop(ia->sa_family, addr, &addr_buf[0], addr_buf.size());

}

Для сокетов из семейства AF_PACKET выведем индекс и адрес интерфейса:

else if (AF_PACKET == ia->sa_family)

{

std::cout << " AF_PACKET: ";

std::ios_base::fmtflags f(std::cout.flags());

// Структура адреса для AF_PACKET.

// MAC-адреса интерфейса, его индексы, некоторые другие параметры.

auto sa = reinterpret_cast<const sockaddr_ll *>(ia);

for (const auto *i = sa->sll_addr;

i < sa->sll_addr + sa->sll_halen; ++i)

std::cout

<< std::hex << std::setfill('0') << std::setw(2)

<< int(*i) << ":";

std::cout.flags(f);

std::cout << "\n    if index = " << sa->sll_ifindex;

}

else

{

// Неизвестный тип адреса.

std::cout << ia->sa_family << " addr type";

}

}

В функции main() просто вызовем функцию getifaddrs():

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

{

ifaddrs *ifa = nullptr;

if (getifaddrs(&ifa) < 0)

{

perror("getifaddrs");

return EXIT_FAILURE;

}

// unique_ptr вызовет пользовательский deleter при удалении, в результате

// чего память для списка будет корректно освобождена.

std::unique_ptr<ifaddrs, decltype(&freeifaddrs)> if_ni(ifa, &freeifaddrs);

Затем выведем поля каждого экземпляра структуры в полученном списке:

for (auto i = if_ni.get(); i->ifa_next != nullptr; i = i->ifa_next)

{

std::cout

<< i->ifa_name << ":";

if (i->ifa_addr)

{

std::cout << "\n    addr";

print_address(i->ifa_addr);

}

if (i->ifa_netmask)

{

std::cout << "\n    netmask";

print_address(i->ifa_netmask);

}

// В слове флагов можно проверить все флаги, которые описаны в этой

// главе.

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

if (i->ifa_flags & IFF_BROADCAST)

{

std::cout << "\n    broadcast addr";

print_address(i->ifa_broadaddr);

}

else if (i->ifa_flags & IFF_POINTOPOINT)

{

std::cout << "\n    point to point addr";

print_address(i->ifa_dstaddr);

}

else

{

std::cout << "\n    no addr";

}

Рассмотрим, как печатается статистика для сокетов типа AF_PACKET:

if (i->ifa_data)

{

std::cout << "\n";

if (i->ifa_addr && AF_PACKET == i->ifa_addr->sa_family)

{

// В Linux для адресов AF_PACKET в ifa_data содержится

// указатель на структуру rtl_link_stats.

const auto stats = static_cast<const rtnl_link_stats*>(

ifa->ifa_data

);

std::cout

<< "    tx_packets = " << stats->tx_packets << "\n"

<< "    rx_packets = " << stats->rx_packets << "\n"

<< "    tx_bytes   = " << stats->tx_bytes << "\n"

<< "    rx_bytes   = " << stats->rx_bytes;

}

else if (i->ifa_addr)

{

std::cout

<< "        " << i->ifa_addr->sa_family

<< " — unimplemented address family to parse data";

}

else

{

std::cout

<< "    address is null, but data is not.";

}

}

std::cout << "\n\n";

}

std::cout << std::endl;

return EXIT_SUCCESS;

}

Аналогично возможно использовать семейство AF_LINK там, где оно есть:

if (i->ifa_addr && AF_LINK == i->ifa_addr->sa_family)

{

const auto data = static_cast<const if_data*>(ifa->ifa_data);

std::cout

<< "    receive_packets = " << int(data.ifi_ipackets)

<< "\n";

}

Вызовем пример и посмотрим на результат:

➭ build/bin/b01-ch10-if_nameindex_get_ifaddrs

lo:

addr AF_PACKET: 00:00:00:00:00:00:

if index = 1

no addr

tx_packets = 6225714

rx_packets = 6225714

tx_bytes   = 376139655

rx_bytes   = 376139655

eno2:

addr AF_PACKET: e4:54:e8:30:20:10:

if index = 2

broadcast addr AF_PACKET: ff:ff:ff:ff:ff:ff:

if index = 2

tx_packets = 6225714

rx_packets = 6225714

tx_bytes  = 376139655

rx_bytes   = 376139655

wlo1:

addr AF_PACKET: 08:71:90:10:20:30:

if index = 3

broadcast addr AF_PACKET: ff:ff:ff:ff:ff:ff:

if index = 3

tx_packets = 6225714

rx_packets = 6225714

tx_bytes   = 376139655

rx_bytes   = 376139655

...

Видим, что у каждого интерфейса есть адрес AF_PACKET. Один и тот же интерфейс появляется в списке несколько раз, только с разными типами адресов, например IPv4 и IPv6:

lo:

addr IPv4: 127.0.0.1

netmask IPv4: 255.0.0.0

no addr

eno2:

addr IPv4: 192.168.5.1

netmask IPv4: 255.255.255.255

broadcast addr IPv4: 192.168.5.1

wlo1:

addr IPv4: 192.168.2.13

netmask IPv4: 255.255.255.0

broadcast addr IPv4: 192.168.2.255

...

lo:

addr IPv6: ::1

netmask IPv6: ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff

no addr

wlo1:

addr IPv6: fe80::6072:680e:bbd4:7acb

netmask IPv6: ffff:ffff:ffff:ffff::

broadcast addr: nullptr, but flag is set

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

Кроме этой функции, есть и другие, например в книге Уильяма Ричарда Стивенса «UNIX разработка сетевых приложений» (изд-во «Питер», 3-е изд., 2007) описаны функции get_ifi_info() и free_ifi_info().

Они делают то же самое, что и getifaddrs() и freeifaddrs(), но возвращают список структур ifi_info. Возвращаемые структуры содержат почти все атрибуты структуры ifaddrs, кроме data, и дополнительно содержат атрибут mtu.

Эти функции устарели, и в современных версиях Linux их уже не встретить, а функция getifaddrs(), которая их заменила, появилась еще во FreeBSD 4.8, поэтому ее допустимо использовать почти в любом случае.

В Python-модуле socket также реализованы функции для работы с интерфейсами:

# Для ОС Windows в Python 3.8 были добавлены функции

# socket.if_nameindex(), socket.if_nametoindex() и socket.if_indextoname().

if sys.platform != "win32" or sys.version_info >= (3, 8):

def if_nameindex() -> list[tuple[int, str]]

def if_nametoindex(name: str, /) -> int

def if_indextoname(index: int, /) -> str

Никаких сложностей использование данных функций не представляет:

>>> import socket

>>> socket.if_nameindex()

[(1, 'lo'), (2, 'eno2'), (3, 'wlo1'), (4, 'docker0'), (19, 'tun0')]

Реализованы они через функции C API, рассмотренного нами выше.

В коде новых версий CPython для Windows получение интерфейсов реализовано через вызов функции GetIfTable2Ex().

Кроме того, для работы с интерфейсами используются функции GetAdaptersInfo() и GetAdaptersAddresses().

При желании из Python всегда можно вызвать C-функцию. Приведем пример для getifaddrs().

Сначала необходимо определить C-структуры:

import ctypes

import ctypes.util

# C-типы из соответствующего модуля.

from ctypes import (

Structure, Union, POINTER,

pointer, get_errno, cast,

c_ushort, c_byte, c_void_p, c_char_p, c_uint, c_int, c_uint16, c_uint32

)

import socket

# Различные структуры, которых нет в модуле socket.

class sockaddr(Structure):

_fields_ = [

('sa_family', c_ushort),

('sa_data', c_byte * 14)

]

class sockaddr_in(Structure):

_fields_ = [

('sin_family', c_ushort),

('sin_port', c_uint16),

('sin_addr', c_byte * 4)

]

class sockaddr_in6(Structure):

_fields_ = [

('sin6_family', c_ushort),

('sin6_port', c_uint16),

('sin6_flowinfo', c_uint32),

('sin6_addr', c_byte * 16),

('sin6_scope_id', c_uint32)

]

class ifa_ifu(Union):

_fields_ = [

('ifu_broadaddr', POINTER(sockaddr)),

('ifu_dstaddr', POINTER(sockaddr))

]

# Структура отображает элемент списка.

class ifaddrs(Structure):

pass

Теперь необходимо установить поля, загрузить динамическую библиотеку, содержащую функции, и получить их адреса:

# Структура ifaddrs содержит указатель того же типа, что и она сама.

# Поэтому предварительно определяется структура, и лишь после этого

# устанавливаются поля.

ifaddrs._fields_ = [

# Вот этот указатель.

('ifa_next', POINTER(ifaddrs)),

('ifa_name', c_char_p),

('ifa_flags', c_uint),

('ifa_addr', POINTER(sockaddr)),

('ifa_netmask', POINTER(sockaddr)),

('ifa_ifu', ifa_ifu),

('ifa_data', c_void_p)

]

# Библиотека LibC.

LibC = ctypes.CDLL(ctypes.util.find_library('c'))

freeifaddrs = LibC.freeifaddrs

getifaddrs = LibC.getifaddrs

Остальное — это классы-обертки, которые упрощают использование функций.

Класс, представляющий сетевой интерфейс:

# Класс содержит данные интерфейса.

class NetworkInterface:

def __init__(self, name):

self.name = name.decode() if isinstance(name, bytes) else name

self.index = socket.if_nametoindex(name)

self.addresses = {}

self.netmask = {}

def __str__(self):

return f'{self.name}:\n    index = {self.index}, ' \

f'\n    addr IPv4 = {self.addresses.get(socket.AF_INET)}' \

f'\n    netmask IPv4 = {self.netmask.get(socket.AF_INET)}' \

f'\n    addr IPv6 = {self.addresses.get(socket.AF_INET6)}' \

f'\n    netmask IPv6 = {self.netmask.get(socket.AF_INET6)}'

И класс для получения списка интерфейсов:

# Класс для получения списка интерфейсов.

class NetworkInterfaces:

def __init__(self):

# Здесь создается указатель.

self._ifa_pointer = POINTER(ifaddrs)()

def __del__(self):

del self._ifa_pointer

def _ifa_iter(self):

p = self._ifa_pointer

while p:

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

yield p.contents

p = p.contents.ifa_next

@staticmethod

def _get_address(sa):

try:

# Если поле адреса нулевое, здесь будет сгенерировано исключение

# ValueError из-за попытки доступа по нулевому указателю.

sa = sa.contents

family = sa.sa_family

addr = None

if socket.AF_INET == family:

sa = cast(pointer(sa), POINTER(sockaddr_in)).contents

addr = socket.inet_ntop(family, sa.sin_addr)

elif socket.AF_INET6 == family:

sa = cast(pointer(sa), POINTER(sockaddr_in6)).contents

addr = socket.inet_ntop(family, sa.sin6_addr)

except ValueError:

# Обработка NULL pointer access.

family, addr = None, None

return family, addr

Метод _get_address() переводит адреса в печатную форму, используя уже известную нам функцию inet_ntop().

В методе получения интерфейсов вызовем функцию getifaddrs() и создадим объекты класса NetworkInterface из возвращенного функцией списка:

def _get_network_interfaces(self):

result = getifaddrs(pointer(self._ifa_pointer))

if result != 0:

raise OSError(get_errno())

try:

retval = []

# Заполнить Python-список интерфейсов.

for ifa in self._ifa_iter():

i_face = NetworkInterface(ifa.ifa_name)

retval.append(i_face)

addr_family, addr = \

NetworkInterfaces._get_address(ifa.ifa_addr)

nm_family, netmask = \

NetworkInterfaces._get_address(ifa.ifa_netmask)

if addr:

i_face.addresses[addr_family] = addr

if netmask:

i_face.netmask[nm_family] = netmask

return retval

finally:

# Python не управляет памятью, выделенной C API,

# поэтому требуется ее освободить.

freeifaddrs(self._ifa_pointer)

@property

def interfaces(self):

return self._get_network_interfaces()

if __name__ == '__main__':

for ni in NetworkInterfaces().interfaces:

print(f'{str(ni)}\n')

При вызове функция getifaddrs() выделяет память, и эту память необходимо освободить, что делается в блоке finally.

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

Флаги интерфейса

Одна из характеристик сетевого интерфейса — флаги, которые представляют собой однобитовые значения и передаются как единое слово флагов.

Передается это слово через поле ifr_flags структуры ifreq. Значения флагов определены в файле net/if.h. Сейчас определено два типа слов: общее слово флагов и расширенное, или частное для устройства, слово флагов. И, соответственно, четыре вызова для работы с ними:

• SIOCGIFFLAGS, SIOCSIFFLAGS — получить или установить общее слово флагов.

• SIOCGIFPFLAGS, SIOCSIFPFLAGS — получить или установить расширенное слово флагов.

Флаги могут относиться к двум группам:

• Флаги административного состояния — устанавливаются пользователем и определяют его пожелания.

• Флаги оперативного состояния — устанавливаются системой и определяют реальное или рабочее состояние интерфейса.

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

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

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

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

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

Подробнее состояния описаны в RFC 2863 «The Interfaces Group MIB».

Прочитать флаги процесс может всегда. Но установка слова флагов является привилегированной операцией.

Далее рассмотрим конкретные флаги.

Флаги активности интерфейса

Флаги активности выключают и включают интерфейс, а также позволяют узнать, работает ли он.

Флаги административного состояния:

• IFF_UP — интерфейс активен, то есть «поднят» администратором и готов к работе.

• IFF_ECHO — интерфейс отправляет эхо-пакеты. Флаг требуется, чтобы предотвратить отправку эхо-пакетов на пакеты, отправленные в ответ данным интерфейсом. Флаг ECHO указывает, что устройству CAN необходимо зациклить пакеты, передаваемые обратно на принимающую сторону. Используется шиной CAN и устройствами IEEE 802.11, то есть Wi-Fi. Присутствует в ядре Linux выше версии 2.6.

Флаги оперативного состояния:

• IFF_RUNNING — интерфейс запущен и работает. Флаг означает, что интерфейс полностью работоспособен и данные могут быть переданы.

• IFF_LOWER_UP — драйвер сигнализирует о том, что физический уровень активизирован. Для Ethernet — если драйвер находится в этом состоянии, есть несущая. Хотя это флаг оперативного состояния, некоторые драйверы, не управляющие реальными устройствами, разрешают пользователям устанавливать этот флаг. Присутствует в ядре Linux выше версии 2.6.

• IFF_DORMANT — физический уровень интерфейса активен, но ожидает внешнего события, например установления соединения по Ethernet. Пока устройство находится в таком состоянии, использовать его нельзя. Присутствует в ядре Linux выше версии 2.6.

Флаги типа среды передачи

Актуальны для устройств, которые поддерживают несколько типов среды. Например, Ethernet-адаптер может использовать коаксиальный кабель, то есть Ethernet Over Coaxical — EoC, или неэкранированную витую пару — UTP.

Как правило, это старые или промышленные адаптеры, такие как изображенный на рис. 10.3. У этого адаптера имеется два различных физических интерфейса.

Флаги позволяют выбрать тип среды:

• IFF_PORTSEL — флаг сигнализирует о том, что устройство способно переключаться между несколькими типами физического носителя. Тип носителя можно выбирать вручную, используя вызов SIOCSIFMAP, описанный ниже.

• IFF_AUTOMEDIA — включен автоматический выбор носителя адаптером. Для адаптеров, которые поддерживают несколько типов носителя, на что указывает IFF_PORTSEL. Устройство автоматически выберет правильный носитель.

Рис. 10.3. Сетевой адаптер с несколькими интерфейсами

Флаги типа интерфейса

Эти флаги информационные, то есть их можно только читать:

• IFF_LOOPBACK — интерфейс является локальной петлей. Флаг проверяется ядром. Это более правильный способ, чем сравнивать имя с "lo".

• IFF_POINTOPOINT — интерфейс представляет собой одну из сторон для соединения «точка-точка», например, если интерфейс «подключен» к интерфейсу абонента через PPP.

Флаги работы маршрутизации и адресации

Позволяют узнать тип адреса либо задать свойства интерфейса, влияющие на обработку адресов:

• IFF_BROADCAST — установлен допустимый широковещательный адрес.

• IFF_NOARP — не используется ARP. Адрес назначения канального уровня не установлен. Обычно устанавливается на интерфейсах, где активен флаг IFF_POINTOPOINT. Очевидно, что для подключений «точка-точка» ARP не требуется.

• IFF_PROMISC — интерфейс находится в неразборчивом режиме, то есть может принимать все данные. В случае Ethernet это будут все кадры, а не только содержащие MAC-адрес, равный адресу интерфейса. Используется в снифферах, которые мы подробнее рассмотрим в главе 22.

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

• IFF_MULTICAST — интерфейс поддерживает многоадресную рассылку. Если флаг не установлен, интерфейс не будет обрабатывать многоадресные пакеты.

• IFF_DYNAMIC — указывает, что при деактивации интерфейса адрес на нем может измениться, например, в случае непостоянных подключений, динамически назначающих IP-адрес. Раньше это были dial-up-подключения, когда до провайдера дозванивались по телефонной линии, используя модем.

Флаги балансировки нагрузки и группировки интерфейсов

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

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

• IFF_MASTER — главный интерфейс в группе балансировки нагрузки. Этот флаг используется прикладным кодом, не влияя на драйвер адаптера.

• IFF_SLAVE — подчиненный интерфейс в группе балансировки нагрузки.

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

Конечно, для работы агрегации каналов флагов недостаточно и, кроме утилиты, нужен еще модуль ядра bonding.

Флаги настройки передачи данных устройством

Определяют, как физическое устройство будет передавать данные. В данном разделе мы выделим только флаг IFF_NOTRAILERS — не использовать трейлерную инкапсуляцию. В Linux этот флаг не используется. Он необходим для совместимости с BSD-системами.

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

Подробнее о трейлерной оптимизации см. в RFC 893 «Trailer Encapsulations», RFC 894 «A Standard for the Transmission of IP Datagrams over Ethernet Networks» и RFC 1042 «A Standard for the Transmission of IP Datagrams over IEEE 802 Networks».

Флаги отладки

Для включения режима отладки существует флаг IFF_DEBUG. Его может использовать драйвер, чтобы отправлять в лог более подробные данные через printk(). На большинство сетевых драйверов установка данного флага не повлияет.

Расширенные флаги состояния

Вызовы SIOCGIFPFLAGS, SIOCSIFPFLAGS нужны для работы с расширенным состоянием устройства. Первый ioctl необходим для их получения, второй — для установки.

В основном эти флаги информационные, что не исключает наличия в отдельных системах другого типа флагов:

• IFF_802_1Q_VLAN — интерфейс представляет устройство 802.1Q VLAN.

• IFF_EBRIDGE — интерфейс установлен как мост Ethernet.

• IFF_SLAVE_INACTIVE — интерфейс представляет собой неактивное ведомое устройство.

• IFF_MASTER_8023AD — интерфейс служит мастером соединения 802.3ad, то есть используется транкинг портов, что, по сути, то же, что и агрегация каналов через EtherChannel, и устройство является главным.

• IFF_MASTER_ALB — интерфейс служит мастером агрегированного канала в режиме balanced-alb, то есть адаптивной балансировки нагрузки.

• IFF_BONDING — интерфейс является ведущим или ведомым устройством. Иными словами, находится в связке, например, для повышения надежности или балансировки нагрузки.

• IFF_SLAVE_NEEDARP — интерфейс должен использовать ARP для проверки целостности канала.

• IFF_ISATAP — это интерфейс RFC 4214 «Intra-Site Automatic Tunnel Addressing Protocol (ISATAP)». ISATAP — экспериментальный протокол туннелирования.

Переименование интерфейса

Для переименования интерфейса можно использовать ioctl SIOCSIFNAME.

В поле ifr_name структуры ifreq задается имя того интерфейса, который требуется переименовать, а в поле ifr_newname — новое имя. Это привилегированная операция, которая может быть произведена только на неактивном интерфейсе.

Реализуем функцию для переименования:

std::string set_interface_name(const std::string &old_name,

const std::string &new_name)

{

// Максимальная длина имени фиксирована размером буфера.

if (new_name.size() >= IFNAMSIZ)

{

throw std::logic_error("Incorrect name size");

}

int sock = socket(PF_INET, SOCK_DGRAM, 0);

if (sock < 0)

{

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

"Opening socket");

}

// Имя — нуль-завершенная строка.

ifreq ifr = {0};

// Размер буфера при копировании превышать нельзя.

std::copy_n(old_name.begin(),

std::min(old_name.size(), static_cast<size_t>(IFNAMSIZ)),

ifr.ifr_name);

std::copy_n(new_name.begin(),

std::min(new_name.size(), static_cast<size_t>(IFNAMSIZ)),

ifr.ifr_newname);

// Переименование.

if (ioctl(sock, SIOCSIFNAME, &ifr) != 0)

{

// Сохранить значение errno.

auto e = errno;

// Нам требуется errno на момент возникновения ошибки.

// Но close() заменяет errno.

close(sock);

throw std::system_error(e, std::generic_category(),

"SIOCSIFNAME ioctl failed");

}

// Вернуть старое имя.

return ifr.ifr_name;

}

Переименуем локальный интерфейс:

➭ sudo build/bin/b01-ch10-if_rename eno2 eth2

SIOCSIFNAME ioctl failed: Device or resource busy

Ошибка возникает потому, что интерфейс активен:

➭ ip a show eno2|head -1

2: eno2: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000

Переведем его в неактивное состояние и попробуем переименовать еще раз:

➭ sudo ip link set down eno2

➭ sudo build/bin/b01-ch10-if_rename eno2 eth2

eno2

➭ ip a show eno2|head -1

2: eth2: <BROADCAST,MULTICAST> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000

➭ ip a show eth2

2: eth2: <BROADCAST,MULTICAST> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000

link/ether e4:54:...

altname enp0s31f6

altname eno2

Видим, что в этот раз переименование сработало.

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

int loop = 30 * 20;

while (loop--)

{

retval = ioctl(sk, SIOCSIFNAME, &ifr);

if (retval == 0)

{

kernel_log(ifr);

break;

}

if (errno != EEXIST)

{

err("error changing net interface name %s to %s: %s\n",

ifr.ifr_name, ifr.ifr_newname, strerror(errno));

break;

}

dbg("wait for netif '%s' to become free, loop=%i\n", udev->name,

(30 * 20) — loop);

usleep(1000 * 1000 / 20);

}

Внимание! Интерфейс будет доступен под своим предыдущим именем, которое отображается как altname, и переименовать его обратно в eno2 без специальных действий не получится.

Чтобы обойти ограничение на длину в IFNAMESZ символов, в Linux существует механизм альтернативных имен, который также используется для того, чтобы иметь имена, основанные на разных критериях для одного интерфейса. Например, одно пользовательское, а второе — зависящее от положения адаптера на шине. Переименование через ioctl сохраняет автоматически назначенное ядром имя как альтернативное. Это имя можно удалить:

➭ sudo ip link property del eno2 altname eno2

После этого обратное переименование будет работать:

➭ sudo build/bin/b01-ch10-if_rename eth2 eno2

eth2

➭ ip link show eth2

Device "eth2" does not exist.

➭ ip link show eno2

2: eno2: <BROADCAST,MULTICAST> mtu 1500 qdisc fq_codel state DOWN mode DEFAULT group default qlen 1000

link/ether e4:54:...

altname enp0s31f6

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

Зачем нужно переименование интерфейса

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

После обновления сервера имена могут измениться, но администратор не должен перенастраивать систему, особенно если таких серверов много. Поэтому в Linux есть утилита ifrename, которая может использоваться для переименования интерфейсов по определенным правилам. Эта утилита использует данный ioctl.

Интерфейс можно переименовать и через команду ip, например:

ip link set eno2 name eth2

Интерфейс может именоваться, например, на основе MAC. Администратор добавляет соответствующее правило udev, что значительно облегчает его работу.

Система устройства udev также использует переименование в процессе своей работы.

Адреса интерфейса

Несколько ioctl служат для установки и получения различных адресов. В них также используются поля вышеприведенной структуры ifreq.

Рассмотрим эти вызовы:

• SIOCGIFADDR, SIOCSIFADDR, SIOCDIFADDR — получить, установить или удалить адрес интерфейса. Для совместимости SIOCGIFADDR возвращает только адреса AF_INET, SIOCSIFADDR принимает адреса AF_INET и AF_INET6, а SIOCDIFADDR удаляет только адреса AF_INET6. Адрес AF_INET можно удалить, установив его в 0 через SIOCSIFADDR. В зависимости от типа адреса используются поля ifr_addr или ifr6_addr с ifr6_prefixlen.

• SIOCGIFDSTADDR, SIOCSIFDSTADDR — получить или установить адрес назначения в соединении «точка-точка». Используется поле ifr_dstaddr. Получить или изменить IPv6-адреса соединения «точка-точка» можно только через сокеты RTNetlink.

• SIOCGIFBRDADDR, SIOCSIFBRDADDR — получить или установить широковещательный адрес. Используется поле ifr_brdaddr.

• SIOCGIFNETMASK, SIOCSIFNETMASK — получить или установить сетевую маску. Используется поле ifr_netmask.

• SIOCGIFHWADDR, SIOCSIFHWADDR — получить или установить аппаратный адрес адаптера. Используется поле ifr_hwaddr, которое представляет собой структуру sockaddr:

• sa_family — содержит тип устройства ARPHRD_*, например ARPHRD_ETHER для Ethernet. В Linux константы объявлены в net/if_arp.h или linux/if_arp.h.

• sa_data — аппаратный адрес L2, начиная с байта 0.

• SIOCSIFHWBROADCAST — установить широковещательный аппаратный адрес устройства из ifr_hwaddr.

• SIOCADDMULTI, SIOCDELMULTI — добавить адрес в многоадресные фильтры канального уровня или удалить его оттуда. Используется поле ifr_hwaddr.

• SIOCGIFCONF — возвращает адреса сетевого уровня. С целью совместимости возвращает только адреса AF_INET. В отличие от других этот ioctl передает структуру ifconf.

Структура ifconf определена следующим образом:

#include <net/if.h>

struct ifconf

{

// Размер буфера в байтах.

int ifc_len;

union

{

// Адрес буфера.

char *ifc_buf;

// Массив структур для получения сетевых адресов.

struct ifreq *ifc_req;

};

};

Этот вызов сложнее остальных. Чтобы его выполнить, требуются следующие действия:

1. Сначала вызвать SIOCGIFCONF с нулевым полем ifc_req. Он вернет количество байтов в ifc_len, необходимое под буфер.

2. Затем выделить буфер размера ifc_len и передать его адрес в ifc_req, а размер — в ifc_len.

3. Ioctl вернет указатель на массив структур ifreq, каждая из которых будет содержать имя интерфейса в ifr_name и IP-адрес в ifr_addr. Поле ifc_len будет содержать количество переданных байтов.

Этот алгоритм вызова показан на рис. 10.4.

Рис. 10.4. Вызов SIOCGIFCONF для получения адресов

Внимание! Если размер, указанный параметром ifc_len, недостаточен для получения всех адресов, ядро пропустит лишние данные и вернет статус успешного завершения. Поэтому всегда заранее выделяйте буфер достаточного размера, как это показано выше.

Рассмотрим на примере. Сначала получим размер буфера:

int main()

{

// Все поля нулевые.

ifconf ifc = {0};

const socket_wrapper::SocketWrapper sock_wrap;

const auto sock = socket_wrapper::Socket(AF_INET, SOCK_DGRAM, 0);

if (sock < 0)

{

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

}

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

if (ioctl(sock, SIOCGIFCONF, &ifc) < 0)

{

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

"SIOCGIFCONF with 0 buffer");

}

// Вывести размер буфера.

std::cout << "Buffer size = " << ifc.ifc_len << std::endl;

assert(ifc.ifc_len);

Повторный вызов заполнит переданный буфер структурами ifreq, содержимое которых мы выведем в цикле:

// Создать буфер нужного размера.

std::vector<char> buffer(ifc.ifc_len);

// Передать его адрес в структуру.

ifc.ifc_buf = buffer.data();

// Сделать повторный вызов.

if (ioctl(sock, SIOCGIFCONF, &ifc) < 0)

{

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

}

// Вывести имена и адреса интерфейсов.

for (auto i = ifc.ifc_req;

i != ifc.ifc_req + ifc.ifc_len / sizeof(ifreq); ++i)

{

std::cout << i->ifr_name << ": ";

if (AF_INET == i->ifr_addr.sa_family)

{

std::array<char, INET_ADDRSTRLEN> buf;

std::cout

<< inet_ntop(AF_INET,

&(reinterpret_cast<sockaddr_in*>(&i->ifr_addr)->sin_addr),

buf.data(), buf.size());

}

else if (AF_INET6 == i->ifr_addr.sa_family)

{

std::array<char, INET6_ADDRSTRLEN> buf;

std::cout

<< inet_ntop(AF_INET6,

&(reinterpret_cast<sockaddr_in6*>(&i->ifr_addr)->sin6_addr),

buf.data(), buf.size());

}

std::cout << std::endl;

}

return EXIT_SUCCESS;

}

Если при доступе к структурам ifconf либо ifreq возникнет ошибка, будет возвращен EFAULT.

Вывод примера:

➭ build/bin/b01-ch10-if_list

Buffer size = 160

lo: 127.0.0.1

wlo1: 192.168.2.13

docker0: 172.18.0.1

tun0: 10.32.51.216

Все перечисленные операции являются привилегированными. Для ioctl получения и установки адресов «точка-точка», широковещательных адресов и сетевой маски с целью совместимости принимаются и возвращаются только адреса AF_INET.

Метрики и другие параметры интерфейса

Структура ifreq также используется в следующих вызовах:

• SIOCGIFMETRIC, SIOCSIFMETRIC — получить или установить метрику интерфейса. Используется поле ifr_metric. В ядре Linux версии 6.x работа с метрикой не реализована. При чтении поля ifr_metric будет возвращен 0, а при установке — ошибка EOPNOTSUPP или EINVAL, в зависимости от стека протоколов.

• SIOCGIFMTU, SIOCSIFMTU — получить или установить MTU. Используется поле ifr_mtu.

• SIOCGIFTXQLEN, SIOCSIFTXQLEN — получить или установить длину очереди передачи устройства с помощью ifr_qlen. Установка длины очереди передачи — привилегированная операция.

• SIOCGIFMAP, SIOCSIFMAP — получить или установить аппаратные параметры интерфейса, используя ifr_map.

Поле ifr_map имеет тип ifmap, который является структурой:

struct ifmap

{

unsigned long mem_start;

unsigned long mem_end;

unsigned short base_addr;

unsigned char irq;

unsigned char dma;

unsigned char port;

};

Интерпретация данной структуры зависит от драйвера устройства и архитектуры.

Интерфейс SIOCETHTOOL

В большинстве Unix-подобных ОС для настройки параметров Ethernet-адаптеров и подобных им существует утилита ethtool.

Также она позволяет устанавливать параметры виртуальных устройств TUN/TAP, описанных в главе 23.

Эта утилита позволяет выполнять очень низкоуровневые операции с адаптерами:

• получать состояние EEPROM адаптера;

• получать статистику;

• проводить самотестирование;

• запускать тестирование кабеля;

• перепрошивать адаптер;

и так далее.

Большинство операций выполняются с правами суперпользователя.

Для работы утилиты используется ioctl SIOCETHTOOL. Семантика вызова сильно зависит от используемой ОС. Заголовочные файлы, которые требуется включить для его работы, также различаются.

В Linux это linux/sockios.h и linux/ethtool.h.

Вызов SIOCETHTOOL принимает структуру ifreq как параметр. В ее поле ifr_name содержится имя адаптера, в поле ifr_data — указатель на структуру ethtool команды.

В каждой структуре команды первым идет 32-битное поле cmd, по которому ядро определяет, что это за команда, и соответствующим образом интерпретирует оставшуюся часть структуры.

Команд достаточно много:

• Работа с EEPROM:

• ethtool_eeprom — дамп EEPROM. Команды ETHTOOL_GEEPROM, ETHTOOL_GMODULEEEPROM, ETHTOOL_SEEPROM.

• ethtool_modinfo — информация о модуле EEPROM плагина. Команда ETHTOOL_GMODULEINFO.

• ethtool_dump — получить дамп устройства. Команды ETHTOOL_GET_DUMP_FLAG, ETHTOOL_GET_DUMP_DATA, ETHTOOL_SET_DUMP. В структуре первыми идут поля версии, флага и длины полученного дампа, которые устанавливает драйвер устройства. Затем поле data, содержащее массив данных указанной длины.

• Информация об устройстве и его проверка:

• ethtool_drvinfo — общая информация о драйвере и устройстве. Команда ETHTOOL_GDRVINFO.

• ethtool_test — вызов самотестирования устройства. Команда ETHTOOL_TEST.

• ethtool_stats — статистика для конкретного устройства. Команда ETHTOOL_GSTATS.

• ethtool_perm_addr — постоянный аппаратный адрес. Команда ETHTOOL_GPERMADDR.

• ethtool_ts_info — содержит временну́ю метку устройства и ассоциацию PHC. Команда ETHTOOL_GET_TS_INFO.

• Управление масштабированием на принимающей стороне — Receive Side Scaling, или RSS:

• ethtool_rxfh_indir — получение или установка косвенности хеш-потока RX. Команды ETHTOOL_GRXFHINDIR и ETHTOOL_SRXFHINDIR. Принимают таблицу косвенности, показанную на рис. 10.4.

• ethtool_rxfh — получение и установка хеш-адреса потока RX или хеш-ключа. Команды ETHTOOL_GRSSH или ETHTOOL_SRSSH.

• ethtool_rx_ntuple — установка или очистка фильтра потока RX. Команда ETHTOOL_SRXNTUPLE.

RReceive Side Scaling

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

Рис. 10.5. Таблица косвенности и обработка данных

RSS гарантирует, что одно соединение всегда обрабатывается на одном процессоре. Для этого процессор выбирается не сразу по хешу данных, а по записям из таблицы косвенности, выбранных по хешу, как это показано на рис. 10.5.

Изначально эта технология была разработана Microsoft, и подробнее о ней можно прочитать в разделе MSDN «Receive Side Scaling»52.

• Управление потоками данных на устройстве:

• ethtool_pauseparam — параметры управления потоком данных Ethernet. Команды ETHTOOL_GPAUSEPARAM и ETHTOOL_SPAUSEPARAM. Они позволяют управлять автосогласованием, а также вставкой кадров паузы в поток приема и передачи, как описано в стандарте IEEE 802.3x «Flow Control».

• ethtool_rxnfc — получение или установка правил классификации потока RX. Команды ETHTOOL_GRXRINGS, ETHTOOL_GRXCLSRLCNT, ETHTOOL_GRXCLSRULE, ETHTOOL_GRXCLSRLALL, ETHTOOL_SRXCLSRLDEL, ETHTOOL_SRXCLSRLINS.

• ethtool_gstrings — набор строк для тегирования данных. Команда ETHTOOL_GSTRINGS.

• ethtool_sset_info — информация о наборе строк. Команда ETHTOOL_GSSET_INFO.

• ethtool_fecparam — параметры FEC, исправления ошибок Ethernet. Команды ETHTOOL_GFECPARAM и ETHTOOL_SFECPARAM.

• Управление соединением:

• ethtool_link_settings — управление соединением и его состояние. Команды ETHTOOL_GLINKSETTINGS и ETHTOOL_SLINKSETTINGS.

• ethtool_channels — настройка количества сетевых каналов. Команды ETHTOOL_GCHANNELS и ETHTOOL_SCHANNELS.

• ethtool_ringparam — параметры кольца RX/TX. Команды ETHTOOL_GRINGPARAM или ETHTOOL_SRINGPARAM.

• Управление питанием и энергопотреблением:

• ethtool_wolinfo — конфигурация Wake-On-Lan. Команды ETHTOOL_GWOL и ETHTOOL_SWOL.

• ethtool_eee — информация об энергоэффективности Ethernet. Команды ETHTOOL_GEEE и ETHTOOL_SEEE.

• Настройка любых параметров и вызов произвольных функций адаптера:

• ethtool_regs — получить содержимое аппаратного регистра. Команда ETHTOOL_GREGS.

• ethtool_coalesce — параметры объединения для IRQ и обновлений статистики. Команды ETHTOOL_GCOALESCE и ETHTOOL_SCOALESCE.

• ethtool_gfeatures — получение состояния функций устройства. Команда ETHTOOL_GFEATURES.

• ethtool_sfeatures — запрос на изменение функций устройства. Команда ETHTOOL_SFEATURES.

• ethtool_per_queue_op — применить подкоманду к очередям в маске. Команда ETHTOOL_PERQUEUE.

Все команды и структуры определены в linux/ethtool.h. Рассматривать подробно мы их не будем, но для примера получим скорость и тип передачи адаптера — дуплексный или нет.

Ethtool в Linux постепенно тоже переходит на интерфейс Netlink. Поэтому описание большинства команд ethtool можно посмотреть в документе «Netlink Interface for Ethtool»53.

Данные команды, используемой для этого, описываются такой структурой ethtool_link_settings:

#include <linux/ethtool.h>

struct ethtool_link_settings

{

// Команда, в данном случае ETHTOOL_GLINKSETTINGS.

__u32 cmd;

// Заданная скорость: 10 Mбит, 100 Mбит, 1 Гбит...

__u16 speed;

// Дуплекс: half, full.

__u8 duplex;

// Тип порта адаптера, например PORT_MII.

__u8 port;

// MDIO-адрес трансивера.

__u8 phy_address;

// Флаг включения автоматического согласования Ethernet.

__u8 autoneg;

// Битовая маска флагов ETH_MDIO_SUPPORTS_* для MDIO-протоколов,

// поддерживаемых интерфейсом. Только для чтения.

__u8 mdio_support;

// Поддержка MDI-X. Поле для чтения.

__u8 eth_tp_mdix;

// Поле для установки режима MDI-X.

__u8 eth_tp_mdix_ctrl;

// Число слов для каждой из поддерживаемых битовых карт

// advertising, lp_advertising.

__s8 link_mode_masks_nwords;

// Используемый трансивер. Позволяет различать типы PHY,

// о которых сообщает PHYLIB. Только для чтения.

__u8 transceiver;

// Режим главного/подчиненного порта.

__u8 master_slave_cfg;

// Состояние главного/подчиненного порта.

__u8 master_slave_state;

__u8 reserved1[1];

__u32 reserved[7];

// Поле, содержащее битовые карты переменной длины:

//   __u32 map_supported[link_mode_masks_nwords];

//   __u32 map_advertising[link_mode_masks_nwords];

//   __u32 map_lp_advertising[link_mode_masks_nwords];

__u32 link_mode_masks[0];

};

Для решения задачи не требуются все поля структуры, и прокомментированы они для справки. Нам достаточно полей ifr_name, speed и duplex.

Поле eth_tp_mdix описывает режим включения кроссовера.

Если этот режим установлен в ETH_TP_MDI_AUTO, интерфейс сам определяет, какие пары в кабеле включены на прием, а какие на передачу, и работает с обычным или кроссоверным кабелем.

Также кроссовер может быть активирован вручную или выключен.

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

extern "C"

{

#include <sys/ioctl.h>

#include <sys/stat.h>

#include <net/if.h>

// Идентификаторы команд.

#include <linux/sockios.h>

// Структура команды.

#include <linux/ethtool.h>

#include <unistd.h>

}

Печать состояния будет производиться в функции print_adapter_params(), которой передается имя адаптера.

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

void print_adapter_params(const std::string &name)

{

const int sock = socket(AF_INET, SOCK_DGRAM, 0);

if (sock < 0)

{

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

}

// Команда.

ethtool_link_settings cmd = { .cmd = ETHTOOL_GLINKSETTINGS };

ifreq ifr = {0};

// Команда.

ifr.ifr_data = reinterpret_cast<caddr_t>(&cmd);

// Название интерфейса.

std::copy_n(name.c_str(), std::min(IF_NAMESIZE, name.size()),

ifr.ifr_name);

try

{

// Первый вызов.

if (ioctl(sock, SIOCETHTOOL, &ifr) < 0)

{

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

"SIOCETHTOOL IOCtl");

}

// От ядра должно прийти отрицательное число.

if (cmd.link_mode_masks_nwords >= 0 ||

cmd.cmd != ETHTOOL_GLINKSETTINGS)

{

throw std::logic_error("Incorrect speed!");

}

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

// Получено реальное значение cmd.link_mode_masks_nwords.

// После этого отправляется реальный запрос.

cmd.cmd = ETHTOOL_GLINKSETTINGS;

cmd.link_mode_masks_nwords = -cmd.link_mode_masks_nwords;

// Еще один вызов.

// Ту же информацию можно получить за один вызов команды

// ETHTOOL_GSET, использующей структуру ethtool_cmd.

// Но в Linux эта команда помечена как устаревшая.

if (ioctl(sock, SIOCETHTOOL, &ifr) < 0)

{

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

"Cannot read speed on interface: " + name);

}

// Проверка скорости на корректность.

if (!ethtool_validate_speed(cmd.speed))

{

throw std::logic_error("Incorrect speed!");

}

// Проверка дуплекса.

if (!ethtool_validate_duplex(cmd.duplex))

{

throw std::logic_error("Incorrect duplex!");

}

Остается только вывести полученные данные на экран:

const std::map<int, std::string> s_map =

{

{SPEED_10, "10 Mb/s"}, {SPEED_100, "100 Mb/s"},

{SPEED_1000, "1 Gb/s"}, {SPEED_2500, "2.5 Gb/s"},

{SPEED_10000, "10 Gb/s"}, {SPEED_UNKNOWN, "Unknown"}

};

const auto speed = s_map.find(cmd.speed);

if (s_map.end() == speed)

{

throw std::logic_error("Incorrect speed!");

}

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

std::cout

<< "Iface = " << ifr.ifr_name << "\n"

<< "Speed = " << speed->second << "\n"

<< "Duplex = "

<< (DUPLEX_HALF == cmd.duplex ? "half" :

(DUPLEX_FULL == cmd.duplex ? "full" : "unknown"))

<< std::endl;

}

catch (const std::exception& e)

{

...

}

close(sock);

}

Вызовем реализованную функцию в main():

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

{

if (argc < 2)

{

std::cerr << argv[0] << " <interface>" << std::endl;

return EXIT_FAILURE;

}

print_adapter_params(argv[1]);

return EXIT_SUCCESS;

}

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

➭ build/bin/b01-ch10-ethtool enp0s20f0u1u3

Iface = enp0s20f0u1u3

Speed = 10 Mb/s

Duplex = half

➭ build/bin/b01-ch10-ethtool tun0

Iface = tun0

Speed = 10 Mb/s

Duplex = full

Вызов ethtool показывает тот же результат:

➭ sudo ethtool tun0|grep -i 'speed\|dup'

Speed: 10Mb/s

Duplex: Full

Данные параметры можно получить не для всех адаптеров: адаптер должен быть активен и поддерживать операцию.

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

Интерфейс MII

Сетевой адаптер — сложное устройство, которое состоит из нескольких блоков:

• MAC — блок нижнего подуровня канального уровня, который управляет доступом к среде передачи.

• PHY — реализует физический уровень Ethernet.

Для соединения этих блоков между собой используется стандартный интерфейс MII, или Media Independent Interface, показанный на рис. 10.6. И этим интерфейсом также можно управлять.

Рис. 10.6. Интерфейс MII

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

Также данный интерфейс может быть использован для подключения внешнего трансивера.

Интерфейс MII синхронный и состоит из двух частей:

• канал MDIO — для приема и передачи данных;

• канал MDC — служебный канал управления.

MII определяет, какими сигналами и по каким линиям обмениваются блок PHY и MAC для передачи и приема данных, тактовые частоты, линии, по которым PHY информирует о коллизиях и ошибках, а также управляющие линии PHY трансивера.

Конкретно MII используется для Fast Ethernet. Существуют его расширения, такие как GMII для гигабитного Ethernet, XGMII для 10-гигабитного Ethernet, который вводит еще один подуровень между PHY и MAC — слой XGXS, 10 Gigabit Ethernet Extended Sublayer и новый протокол XAUI для взаимодействия этого слоя и PHY.

Также существуют расширения для различных задач:

• RMII — Reduced media-independent interface для микроконтроллеров.

• SMII — Serial media-independent interface для последовательной передачи, когда количество линий на порт ограниченно.

• HSGMII — High serial gigabit media-independent interface для увеличения скорости на SGMII до 2,5 Гбит/с.

• QSGMII — Quad serial gigabit media-independent interface для увеличения скорости на SGMII до 5 Гбит/с.

И прочие.

Но интерфейс, предоставляемый ОС, достаточно ограничен, и в большинстве случаев при работе через него различия не будут заметны.

Для работы с MII в Linux есть утилита mii-tool, в основном позволяющая выбирать скорость адаптера: 100baseTx-FD, 100baseTx-HD, 10baseT-FD, 10baseT-HD и переключать автосогласование.

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

Для работы с MII существуют три основных ioctl:

• SIOCGMIIPHY — получить адрес используемого PHY-блока.

• SIOCGMIIREG — прочитать регистр PHY-блока.

• SIOCSMIIREG — записать регистр PHY-блока.

Чтобы ими пользоваться, требуется подключить заголовочный файл linux/mii.h.

Используемая в ifr_data структура для работы с этими командами — mii_ioctl_data:

struct mii_ioctl_data

{

// Адрес блока PHY.

__u16 phy_id;

// Номер регистра PHY.

__u16 reg_num;

// Значение для записи в регистр.

__u16 val_in;

// Значение, прочитанное из регистра.

__u16 val_out;

};

Рассмотрим пример чтения регистров. Сначала создадим сокет, инициализируем структуру ifreq именем заданного интерфейса:

extern "C"

{

#include <linux/mii.h>

#include <sys/types.h>

#include <sys/socket.h>

#include <sys/ioctl.h>

#include <net/if.h>

#include <linux/sockios.h>

#include <linux/types.h>

#include <netinet/in.h>

#include <unistd.h>

}

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

{

...

ifreq ifr = {0};

std::copy_n(iface_name.c_str(),

std::min(static_cast<size_t>(IF_NAMESIZE — 1),

iface_name.size()), ifr.ifr_name);

...

Затем выполним ioctl для получения номера PHY-блока, а в цикле прочитаем его регистры и выведем их содержимое на экран:

// Прочитать адрес PHY-блока адаптера.

if (ioctl(sock, SIOCGMIIPHY, &ifr) < 0)

{

close(sock);

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

}

mii_ioctl_data *mii_data = reinterpret_cast<mii_ioctl_data *>(

&ifr.ifr_data);

// Цикл чтения регистров: регистры до 0x1c — основные регистры MII.

for(int i = 0 ; i <= 0x1с; ++i)

{

// Установить номер регистра.

mii_data->reg_num = i;

// Выполнить ioctl для чтения значения.

if (ioctl(sock, SIOCGMIIREG, &ifr) < 0)

{

std::array<char, 256> buffer{};

// Ошибка чтения одного регистра, но другие регистры читать можно.

std::cerr

<< "Ioctl error: "

<< ::strerror_r(errno, buffer.data(), buffer.size())

<< std::endl;

continue;

}

std::cout

<< std::hex

<< "PHY addr: 0x" << mii_data->phy_id

<< ", reg: 0x" << mii_data->reg_num

<< ", value: 0x" << mii_data->val_out

<< std::endl;

}

close(sock);

return EXIT_SUCCESS;

}

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

➭ sudo build/bin/b01-ch10-mii-example eno2

Interface: eno2

PHY addr: 0x2, reg: 0x0, value: 0x1140

PHY addr: 0x2, reg: 0x1, value: 0x7909

PHY addr: 0x2, reg: 0x2, value: 0x154

PHY addr: 0x2, reg: 0x3, value: 0xa0

PHY addr: 0x2, reg: 0x4, value: 0xde1

PHY addr: 0x2, reg: 0x5, value: 0x0

PHY addr: 0x2, reg: 0x6, value: 0x4

Ioctl error: Input/output error

Ioctl error: Input/output error

PHY addr: 0x2, reg: 0x9, value: 0x200

PHY addr: 0x2, reg: 0xa, value: 0x0

...

Видим содержимое регистров блока PHY. Некоторые регистры прочитать не удалось.

Утилита mii-tool работает так же: использует вызовы ioctl, то есть читает регистры, а затем интерпретирует результат.

Для интерпретации в заголовочном файле linux/mii.h определен набор констант, задающих назначение регистров, флаги, маски полей. Например, MII_BCMR — основной регистр управляющего режима, через который можно осуществить сброс адаптера, если записать в него флаг BCMR_RESET. А флаг BMCR_SPEED100 позволяет определить скорость — 100 или 10 Мбит/с. Установка этого флага приведет к переходу на заданную скорость.

За подробностями читатель может обратиться к исходному коду утилиты mii-tool54, которая содержится в пакете net-tools.

ARP

ARP — протокол разрешения адресов, который используется для преобразования между аппаратными адресами уровня 2 и адресами протокола IPv4 в сетях с прямым подключением узлов.

PDU данного протокола имеет простую структуру, в которой содержится код операции, аппаратные и сетевые адреса. Она показана на рис. 10.7.

Рис. 10.7. Структура ARP-пакета

На рис. 10.7 видно, как протокол работает в случае наличия общей шины. В случае моста все работает аналогично.

Если узел 1 хочет отправить данные узлу 2, зная его IP, но его аппаратный адрес ему неизвестен, он отправит запрос, содержащий IP узла 2 и свой MAC на широковещательный MAC-адрес.

Рис. 10.8. Работа ARP

Узел 2 ответит узлу 1 PDU, содержащим свой MAC. Когда узел 1 получит ответ, он добавит MAC-адрес узла 2 в свой кэш и после этого сможет обмениваться с этим узлом как обычно.

ARP описан в RFC 826 «An Ethernet Address Resolution Protocol». Задача ARP — найти канальный адрес, или MAC адаптера по IP-адресу.

Если этот компьютер доступен в общем широковещательном домене, он пришлет ответ на широковещательный ARP-запрос. Его MAC-адрес будет помещен в кэш и в дальнейшем использован для обмена с этим узлом, когда запрос производится к его IP-адресу. Широковещательный домен — это, например, общая шина, VLAN или физические подсети, соединенные коммутаторами.

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

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

Система поддерживает статическую ARP-таблицу, содержащую записи сопоставления IP-адресов с MAC-адресами. Пользователь может добавлять или удалять записи в этой таблице.

Кэш сопоставлений между аппаратными адресами и адресами протоколов сетевого уровня:

• Динамический ARP-кэш, записи в который помещаются, когда устройство узнает MAC-адрес устройства из ARP-запроса или ответа.

• Результат обнаружения соседей по IPv6.

Размер кэша ограничен: старые записи удаляются сборщиком мусора. Записи, помеченные как постоянные, не удаляются никогда.

Запись считается устаревшей, если в течение некоторого периода для нее отсутствует ответ.

Ответ может прийти:

• С более высокого уровня, например, после успешного TCP ACK.

• От протокола, уведомляющего о ходе пересылки. Для этого используется флаг MSG_CONFIRM в функциях send().

Если ответа нет, ARP пытается повторить попытку, сначала запрашивая новый MAC у локального демона ARP.

Если это не удается и известен старый MAC, отправляется реальный запрос к узлу. Если и это не удается, ARP отправляет в сеть новый запрос.

Кэшем можно управлять с помощью ioctl на дескрипторе сокета из семейства AF_INET:

• SIOCSARP — добавить запись кэша;

• SIOCGARP — получить запись кэша;

• SIOCDARP — удалить запись кэша.

Параметром данных ioctl является структура, определенная в net/if_arp.h:

// Структура — параметр ioctl.

struct arpreq

{

// Сетевой адрес — "Protocol Address".

struct sockaddr arp_pa;

// Аппаратный адрес.

struct sockaddr arp_ha;

// Флаги.

int arp_flags;

// Сетевая маска протокола.

// Действительна, только если ATF_NETMASK установлен.

struct sockaddr arp_netmask;

// Имя адаптера.

char arp_dev[16];

};

Флаги могут принимать следующие значения:

// Запись используется, старый флаг.

#define ATF_INUSE 0x01

// Нормальная запись с корректным аппаратным адресом.

#define ATF_COM 0x02

// Постоянная запись.

#define ATF_PERM 0x04

// Опубликованная другим узлом запись, полученная из сети через ARP.

#define ATF_PUBL 0x08

// Устаревший флаг.

#define ATF_USETRAILERS 0x10

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

#define ATF_NETMASK 0x20

// Не отвечать этим адресом.

#define ATF_DONTPUB 0x40

// Запись добавлена автоматически.

#define ATF_MAGIC 0x80

Узнать подробнее о поддержке ARP можно в man 7 arp.

В Linux существует возможность управления кэшем ARP с помощью сокетов Netlink.

Маршруты

Внимание! Данные вызовы устарели. Используйте сокеты RTNetlink в Linux, как это делает команда ip route. В Solaris и BSD-системах также есть сокеты AF_ROUTE, которые описаны Стивенсом. Упоминание данных ioctl нужно для того, чтобы понимать, как организовать работу с маршрутами в других, как правило, более старых ОС, а также как это было сделано в исторических утилитах, которые могут до сих пор использоваться.

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

Таблица может использоваться разными протоколами, не только разными версиями IP. За работу с таблицей отвечали следующие вызовы:

• SIOCADDRT — добавить одну запись в таблицу маршрутизации;

• SIOCDELRT — удалить одну запись из таблицы маршрутизации.

Структура записи таблицы маршрутизации очень сильно зависит от платформы. Например, в Linux структура определена в net/route.h таким образом:

// Передавать эту структуру надо в SIOCADDRT и SIOCDELRT.

struct rtentry

{

unsigned long int rt_pad1;

// Адрес назначения.

struct sockaddr rt_dst;

// Адрес шлюза.

struct sockaddr rt_gateway;

// Маска сети назначения.

struct sockaddr rt_genmask;

// Различные флаги.

unsigned short int rt_flags;

short int rt_pad2;

unsigned long int rt_pad3;

unsigned char rt_tos;

unsigned char rt_class;

#if __WORDSIZE == 64

short int rt_pad4[3];

#else

short int rt_pad4;

#endif

// Метрика, требуемая для совместимости.

short int rt_metric;

// Имя устройства: к нему привязан маршрут.

// Пользователь не может указывать поле интерфейса.

// Оно заполняется процедурами маршрутизации.

char *rt_dev;

// MTU для маршрута.

unsigned long int rt_mtu;

// Ограничение окна для маршрута.

unsigned long int rt_window;

// Начальное RTT для маршрута.

unsigned short int rt_irtt;

};

// Необходимо для совместимости.

#define rt_mss rt_mtu

В Solaris или BSD-системах данная структура будет отличаться. Общие значения флагов маршрута:

// Значения флагов для поля rt_flags.

// Маршрут работоспособен.

#define RTF_UP 0x0001

// Пункт назначения — шлюз.

#define RTF_GATEWAY 0x0002

// Запись в таблице адресует узел.

// Если флаг не установлен, запись адресует сеть.

#define RTF_HOST 0x0004

// Восстановить маршрут после тайм-аута.

#define RTF_REINSTATE 0x0008

// Динамически создан, то есть через редирект.

#define RTF_DYNAMIC 0x0010

// Динамически изменен, то есть через редирект.

#define RTF_MODIFIED 0x0020

// Для этого маршрута задан специальный MTU.

#define RTF_MTU 0x0040

// Нужно для совместимости.

#define RTF_MSS RTF_MTU

// Ограничение размера окна для маршрута.

#define RTF_WINDOW 0x0080

// Для маршрута задан начальный RTT.

#define RTF_IRTT 0x0100

// Отклонить маршрут.

#define RTF_REJECT 0x0200

// Маршрут добавлен вручную.

#define RTF_STATIC 0x0400

// Внешний резолвер.

#define RTF_XRESOLVE 0x0800

// Запрещено использовать маршрут для пересылки данных.

#define RTF_NOFORWARD 0x1000

// Перейти к следующему классу.

#define RTF_THROW 0x2000

// Не отправлять пакеты, которые не могут быть фрагментированы.

// Например, с установленным DF в IP-заголовке.

#define RTF_NOPMTUDISC  0x4000

Флаги IPv6 и прочие:

// Флаги для IPv6.

// default — выучен через обнаружение соседей (IPv6 Neighbor Discovery).

#define RTF_DEFAULT     0x00010000

// Резервный вариант: нет маршрутизаторов в канале.

#define RTF_ALLONLINK 0x00020000

// Маршрут addrconf, полученный через уведомления от маршрутизатора

// через Router Advertisement.

#define RTF_ADDRCONF 0x00040000

// Специфичный для соединения.

#define RTF_LINKRT 0x00100000

// Маршрут без следующего узла.

#define RTF_NONEXTHOP 0x00200000

// Запись кэша.

#define RTF_CACHE 0x01000000

// flow significant route

#define RTF_FLOW 0x02000000

// Маршрутизация на основе политик.

#define RTF_POLICY 0x04000000

#define RTCF_VALVE 0x00200000

#define RTCF_MASQ 0x00400000

#define RTCF_NAT 0x00800000

#define RTCF_DOREDIRECT 0x01000000

#define RTCF_LOG 0x02000000

#define RTCF_DIRECTSRC 0x04000000

#define RTF_LOCAL 0x80000000

#define RTF_INTERFACE 0x40000000

#define RTF_MULTICAST 0x20000000

#define RTF_BROADCAST 0x10000000

#define RTF_NAT 0x08000000

В таблице маршрутизации существует три типа записей:

• для узла;

• для всех узлов в определенной сети;

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

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

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

Вызовы для сокета

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

Данные вызовы действуют только на конкретный сокет и не меняют системные настройки, поэтому не являются привилегированными. К структуре ifreq они также не имеют никакого отношения: каждый вызов использует свой тип данных.

Рассмотрим эти вызовы подробнее:

• SIOCGSTAMP — возвращает структуру timeval с временем получения последнего пакета, отправленного пользователю. Это полезно для точного измерения RTT — времени круговой задержки. Данный ioctl используется, когда параметры сокета SO_TIMESTAMP и SO_TIMESTAMPNS не установлены на сокете, иначе возвращается временна́я метка последнего полученного пакета до их установки. Если же такой пакет не был получен, ioctl() возвращает –1 и ошибку ENOENT.

• SIOCSPGRP — установить процесс или группу процессов, которые должны получать сигналы SIGIO, когда становится возможным ввод-вывод, или SIGURG, когда доступны внеполосные данные, описанные в главе 6. Аргумент ioctl — указатель на pid_t. Для указания PID процесса значения положительные, для указания группы — отрицательные, 0 сбрасывает получателя.

• SIOCGPGRP — получить идентификатор процесса или группы процессов, которые получают сигналы SIGIO или SIGURG. Вернет положительный идентификатор для процесса, отрицательный для группы или 0, если идентификатор не установлен.

• FIOASYNC — включить или отключить режим асинхронного ввода-вывода. В асинхронном режиме, когда ввод или вывод станет возможным, процесс получит сигнал SIGIO. В Linux сигнал может быть переопределен через операцию F_SETSIG, выполняемую функцией fcntl(). Данный ioctl просто изменяет флаг O_ASYNC.

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

FIOASYNC включает уведомления при асинхронном вводе-выводе. Эта команда заставляет ядро отправлять сигнал SIGIO или SIGPOLL процессу или группе процессов, когда дескриптор готов к вводу-выводу. Эту функциональность реализуют только сокеты, tty и псевдо-tty.

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

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

O_NONBLOCK обычно используется в сочетании с вызовами select() или poll() и аналогами, чтобы реализовывать асинхронные системы, рассмотренные в книге 2.

Внимание! Использование ioctl FIOASYNC — нестандартный вариант установки опции O_ASYNC, поэтому лучше его не использовать. Установку флага в Unix-подобных системах лучше производить через рассмотренную далее функцию fcntl().

Вызовы для TCP

Для TCP существует несколько ioctl. В скобках для некоторых из них указаны их синонимы:

• FIONREAD или SIOCINQ — получить количество непрочитанных данных в очереди приема, то есть сколько данных можно прочитать за один вызов recv(). Если сокет находится в состоянии LISTEN, будет возвращена ошибка EINVAL. SIOCINQ определен в linux/sockios.h. FIONREAD определен в sys/ioctl.h. Вероятно, в переносимом коде предпочтительнее использовать FIONREAD, так как в ОС Windows он также присутствует.

• SIOCATMARK — возвращает значение, не равное нулю, если входящий поток данных находится на отметке срочности. Если параметр сокета SO_OOBINLINE установлен, а SIOCATMARK возвращает ненулевое значение, при следующем чтении из сокета будут возвращены внеполосные данные. Если параметр сокета SO_OOBINLINE не установлен, а SIOCATMARK возвращает ненулевое значение, при следующем чтении из сокета будут возвращены байты, следующие за срочными данными, если в recv() не был указан флаг MSG_OOB. О внеполосных данных подробнее см. в главе 6.

• TIOCOUTQ или SIOCOUTQ — возвращает количество неотправленных данных в очереди отправки, если сокет находится в состоянии LISTEN, в противном случае будет возвращена ошибка EINVAL. SIOCOUTQ определен в linux/sockios.h. Альтернативный TIOCOUTQ определен в sys/ioctl.h.

Вызовы ioctl постепенно стандартизуются в POSIX. Часто вместо них предлагается использовать функции. Поэтому и для вызова SIOCATMARK, вероятно, стоит использовать функцию sockatmark().

С другой стороны, в ОС Windows эта функция еще отсутствует, и в ней придется использовать SIOCATMARK.

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

unsigned long bytes_available;

if (ioctl(socket_fd, FIONREAD, &bytes_available) < 0)

{

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

}

if (bytes_available > 0)

{

std::vector<char> buffer(bytes_available);

...

Вызовы для UDP

Для UDP доступны следующие вызовы:

• FIONREAD или SIOCINQ — вернуть размер в байтах следующей ожидающей в очереди приема дейтаграммы или 0, если ожидающих дейтаграмм нет.

• TIOCOUTQ или SIOCOUTQ — вернуть количество байтов данных в очереди отправки.

Внимание! При использовании FIONREAD невозможно отличить случай, когда ожидающих дейтаграмм нет, от случая, когда следующая ожидающая дейтаграмма имеет нулевой размер данных. Лучше использовать асинхронные вызовы типа select() или poll(), которые будут рассмотрены в книге 2.

Вызовы для Unix-сокетов

Для Unix-сокетов в Linux поддерживается единственный вызов — SIOCINQ, получающий количество непрочитанных данных в очереди приема. Он поддерживается только на сокетах типа SOCK_STREAM и работает аналогично тому же вызову, описанному для TCP.

Прочие вызовы

Для работы с дескрипторами в Linux существуют два нестандартных ioctl, работающих с флагом CLOEXEC:

• FIOCLEX — установить флаг CLOEXEC на дескриптор;

• FIONCLEX — сбросить флаг.

Используются они так:

int r = ioctl(fd, FIOCLEX);

Конечно, мы рассмотрели не все «сетевые» ioctl. В BSD-стеке, например, существует достаточно много вызовов, общих между системами, но отсутствующих в Linux. Например, SIOCGIFSTATS для получения статистики интерфейса или SIOCGIFTYPE для получения его типа. Систем достаточно много, существуют еще AIX, Solaris, Haiku — наследница BeOS, и каждая включает свои собственные вызовы, даже если некоторые системы используют BSD-стек.

Задачи написать всеобъемлющий справочник не ставилось, поэтому мы не рассматривали данные системы.

Резюме

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

При установке или получении параметров сетевых интерфейсов обычно указывается, с чем работать, путем передачи имени сетевого интерфейса ifr_name, его индекса либо индекса для IPv6 в ifr6_ifindex. Вызовы ioctl можно использовать для того, чтобы получить индекс некоторого интерфейса по его имени, а также решить обратную задачу. Затем через ioctl можно получить различные характеристики интерфейса, такие как его тип и тип среды передачи. Также ioctl позволяет изменять параметры интерфейса: переименовать интерфейс, менять его адреса, маски, метрики, флаги и пр.

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

Широко известная утилита для работы с сетевыми параметрами — ethtool — использует ioctl-вызов SIOCETHTOOL и берет основные данные о сетевом интерфейсе из этого вызова.

Для более низкоуровневой работы существует утилита mii-tool. Адаптер состоит из блоков MAC и PHY, и для их соединения используется Media Independent Interface. Эта утилита позволяет управлять данным интерфейсом, для чего тоже использует ioctl-вызовы.

В сетях с прямым подключением узлов для преобразования между аппаратными и сетевыми адресами используется ARP. Система поддерживает кэш сопоставлений между адресами разных уровней соседних узлов. Работа с кэшем ARP производится также с помощью ioctl.

С помощью ioctl можно также работать напрямую с сокетом, менять настройки работы для сетевых протоколов TCP, UDP и т.п. Ранее в Unix-подобных системах этот вызов был основным интерфейсом, позволяющим взаимодействовать с аппаратным обеспечением и ресурсами системы на низком уровне. Он до сих пор широко используется в драйверах и системном программировании, но сейчас постепенно вытесняется интерфейсами, подобными сокетам Netlink в Linux.

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

1. В чем отличие сетевого интерфейса от сетевого адаптера?

2. Каковы основные параметры, которые принимает вызов ioctl()?

3. Какие проблемы могут возникнуть при осуществлении неправильного вызова ioctl?

4. Имеет ли код ioctl структуру? Почему?

5. Зачем нужен индекс интерфейса, если в вызове ioctl() можно использовать название интерфейса?

6. Как получить адрес сетевого интерфейса?

7. Какие есть способы перечисления сетевых интерфейсов в Linux?

8. Для чего может потребоваться переименование сетевого интерфейса?

9. Почему операционная система после переименования интерфейса не удаляет старое название?

10. Какие типы состояния есть у сетевого интерфейса? Флаги какого состояния можно устанавливать, а какого нет? Почему?

11. Как обычно передаются флаги в вызовах ioctl()?

12. За что отвечает флаг IFF_AUTOMEDIA?

13. Для чего нужны флаги расширенного состояния?

14. Что случится, если размер буфера недостаточен при вызове SIOCGIFCONF? Как выделить буфер нужного размера?

15. Для выполнения каких задач служит вызов SIOCETHTOOL?

16. Что такое PHY- и MAC-блоки?

17. Что такое MII? Для чего используется управление MII?

18. Для чего используется ARP? Используется ли он в IPv6-сетях?

19. Какие параметры ARP возможно изменять?

20. Для чего используется вызов FIOASYNC и стоит ли его использовать?

21. Для чего используется вызов FIONREAD и стоит ли его использовать?

22. Какие еще вызовы для сокета кажутся вам полезными для решения своих задач при написании сетевых приложений?

23. Чем заменяется ioctl в современных ядрах Linux, например устаревшие вызовы для работы с маршрутами?

24. Установите с помощью вызова ioctl() SIOCSIFMTU MTU на сетевом устройстве в значение 500 байт. Убедитесь что новое значение установлено с помощью ioctl().

25. Напишите программу, которая использует ioctl SIOCGIFADDR, чтобы получить и вывести IP-адрес заданного сетевого интерфейса.

26. Создайте приложение, которое использует ioctl SIOCGIFHWADDR, чтобы получить и поменять MAC-адрес заданного сетевого интерфейса.

52 https://learn.microsoft.com/en-us/windows-hardware/drivers/network/introduction-to-receive-side-scaling

53 https://docs.kernel.org/networking/ethtool-netlink.html

54 https://github.com/ecki/net-tools/blob/master/mii-tool.c

Глава 11. Сокеты Netlink

А-а-а! Этот интерфейс ужасен.

Линус Торвальдс, новостная группа fa.linux.kernel, 2007

Введение

Сокеты служат не только для обмена данными между приложениями, но и для управления поведением операционной системы. Для этого в ядре Linux и некоторых Unix-подобных ОС, таких как FreeBSD, имеются сокеты домена AF_NETLINK — IPC-механизм (Inter-Process Communication, механизм межпроцессного взаимодействия), используемый в основном для получения доступа к сетевому API ядра из процессов пользовательского пространства. Этот интерфейс показан на рис. 11.1.

Он был разработан как гибкая замена ioctl и предоставляет интерфейсы конфигурации ядра и мониторинга, связанные с сетью. Например, команда ip в Linux активно использует подсистему Netlink. И это важное различие с командой ifconfig, использующей ioctl.

Рис. 11.1. Подсистема Netlink

Контроллер nlctrl на рис. 11.1 — это «метасемейство» Genetlink, предоставляющее информацию обо всех семействах Netlink-сокетов, зарегистрированных в ядре. Данные сокеты описаны в man 7 netlink, man 7 rtnetlink, man 3 netlink, man 3 rtnetlink и в RFC 3549 «Linux Netlink as an IP Services Protocol».

В том виде, как она известна сейчас, подсистема Netlink была спроектирована и реализована Алексеем Кузнецовым, одним из ключевых разработчиков ядра Linux, внесшим значительный вклад в разработку сетевой подсистемы.

Ранее это было символьное устройство, реализованное Аланом Коксом.

В данной главе мы рассмотрим основные концепции и принципы работы сокетов Netlink. Узнаем, как обработать подключение или отключение интерфейсов, изменение состояния ссылок и другие события. А также научимся выполнять через них запросы к ядру и получать ответы. В конце главы узнаем про альтернативы в других ОС.

Создание сокетов Netlink и их типы

Предоставляемый сокетами Netlink API включает следующие основные части:

• Интерфейс на основе сокетов для взаимодействия из пользовательского пространства с ядром и модулями ядра.

• Набор макросов для Netlink-сокетов и для сокетов управления маршрутизацией — NETLINK_ROUTE.

• Пользовательские библиотеки libnl, libmnl, libnetlink и прочие, а также пакеты на Python, например pyroute2.

Сокет Netlink создается следующим образом:

#include <asm/types.h>

#include <sys/socket.h>

#include <linux/netlink.h>

int netlink_socket = socket(AF_NETLINK, socket_type, netlink_family);

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

Сокеты Netlink оперируют дейтаграммами, поэтому для типа сокета допустимы значения SOCK_RAW или SOCK_DGRAM — они ничем не различаются.

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

К сети имеют отношение следующие типы сокетов:

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

• NETLINK_NFLOG — журнал Netfilter уровня пользователя.

• NETLINK_W1 — сообщения подсистемы 1-wire.

• NETLINK_USERSOCK — зарезервирован для протоколов сокетов пользовательского режима.

• NETLINK_SOCK_DIAG — запрашивает у ядра ОС информацию о сокетах различных семейств.

• NETLINK_XFRM — фреймворк для преобразования пакетов, например, для шифрования их полезной нагрузки. Используется для реализации набора протоколов IPsec, протокола сжатия полезной нагрузки IP и функций мобильного IPv6.

• NETLINK_ISCSI — поддержка iSCSI, используемого для получения доступа к СХД.

• NETLINK_FIB_LOOKUP — позволяет из пользовательского пространства искать адреса в FIB.

• NETLINK_NETFILTER — подсистема сетевого фильтра.

• NETLINK_RDMA — управление RDMA, то есть удаленным доступом к памяти для шины InfiniBand.

• NETLINK_IP6_FW — получение IPv6-пакетов из сетевого фильтра в пространство пользователя. Используется модулем ядра ip6_queue.

• NETLINK_DNRTMSG — получение сообщений маршрутизации DECnet.

• NETLINK_KOBJECT_UEVENT — получение сообщений ядра в пространстве пользователя.

• NETLINK_INET_DIAG — запрос информации о сокетах различных семейств протоколов из ядра.

• NETLINK_GENERIC — фреймворк, упрощающий использование Netlink для взаимодействия компонентов ядра.

• NETLINK_FIREWALL — устаревший тип, который использовался для передачи IPv4-пакетов из сетевого фильтра на пользовательский уровень.

Пример создания сокета RTNetlink:

#include <asm/types.h>

#include <linux/netlink.h>

#include <linux/rtnetlink.h>

#include <sys/socket.h>

int rtnetlink_socket = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);

Константа NETLINK_ROUTE задает тип Netlink API.

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

Если пользовательское приложение допустит переполнение буфера, вовремя не получив данные из сокета, то любая следующая операция с сокетом вернет ошибку ENOBUFS «No buffer space available».

При получении ошибки недостаточного места в буфере приложение должно закрыть сокет и создать его заново.

Переполнение буфера возможно не только со стороны приложения, но и со стороны ядра. Поэтому для повышения надежности и подтверждения того, что ядро получило и обработало отправленные ему данные, можно затребовать подтверждение при отправке пакета, выставив в нем флаг NLM_F_ACK. Тогда ядро отправит в ответ сообщение типа NLMSG_ERROR с полем ошибки, установленным в 0, если иначе запрос не возвращает никаких данных.

Для пакетов с ошибкой ядро будет пытаться отправлять сообщение NLMSG_ERROR с ненулевым кодом.

Формат адресов

Адрес клиента Netlink как в пространстве пользователя, так и в ядре описывается структурой sockaddr_nl. Структура поддерживает как адреса «точка-точка», так и группы многоадресной рассылки:

struct sockaddr_nl

{

// AF_NETLINK.

sa_family_t nl_family;

// Заполнение нулями.

unsigned short nl_pad;

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

pid_t nl_pid;

// Маска multicast группы.

__u32 nl_groups;

};

Поле nl_family всегда AF_NETLINK, а nl_pad всегда нулевое. Остальные поля содержат компоненты адреса:

• nl_pid — идентификатор процесса, который владеет сокетом Netlink.

• nl_groups — битовая маска, представляющая номера групп Netlink. Это поле поддерживает многоадресные рассылки.

Каждое семейство Netlink имеет набор из 32 групп многоадресной рассылки. При вызове bind() поле nl_groups должно содержать маску групп, которые процесс собирается прослушивать.

Значение поля, равное 0, означает, что многоадресные рассылки приниматься не будут. Типы групп могут быть объединены через условие «ИЛИ».

Например, следующие типы групп получают уведомления об изменении:

• RTMGRP_LINK — сетевых интерфейсов: удалении, добавлении, включении или выключении;

• RTMGRP_IPV4_IFADDR — IPv4-адресов интерфейсов;

• RTMGRP_IPV6_IFADDR — IPv6-адресов интерфейсов;

• RTMGRP_IPV4_ROUTE — таблиц маршрутизации для IPv4;

• RTMGRP_IPV6_ROUTE — таблиц маршрутизации IPv6.

Когда сообщение отправляется с nl_pid, равным 0, оно адресует ядро или получателя, находящегося в ядре. Для процесса пользовательского пространства nl_pid обычно представляет собой уникальный идентификатор процесса, чаще всего PID процесса, создавшего данный сокет.

Внимание! Идентификатор Netlink-сокета nl_pid всегда должен быть уникален, поэтому если процесс владеет несколькими сокетами Netlink, идентификатор будет отличаться от PID.

Установить nl_pid можно через вызов bind(). В этом случае приложение должно обеспечивать его уникальность.

Несмотря на то что pid_t, определенный в ядре, занимает в памяти 4 байта, как и поле nl_pid в пакете Netlink, максимально возможный pid процесса меньше, чем максимальное значение uint32. Это позволяет использовать в процессе старшие байты этого поля для задания уникальных значений nl_pid нескольким сокетам, сохраняя идентификатор процесса. Подробно реализацию можно увидеть в функции generate_local_port(), входящей в состав библиотеки libnl, которая будет рассмотрена в следующей главе.

Сама функция достаточно объемная, и нас интересует только ее суть:

static uint32_t generate_local_port()

{

uint32_t n;

static uint16_t idx_state = 0;

uint32_t pid = getpid() & 0x3FFFFF;

// Сначала формируется случайный ненулевой idx_state.

...

int i = idx_state >> 5;

n = idx_state;

for (int j = 0; j < 32; j++)

{

// Далее выполняется рандомизированный проход по индексу,

// при этом блок 0 остается последним.

...

for (int m = 0; m < 32; m++)

{

...

// PID_MAX_LIMIT — максимальный номер PID примем равным 2^22.

// Остается 10 бит, то есть 1024 уникальных порта на приложение.

// Убедиться, что не будет возвращен 0.

pid = pid + (n << 22);

return pid ? pid : 1024;

}

}

return 0;

}

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

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

В современных ядрах Linux функцию bind() можно вызывать повторно, и это можно использовать, чтобы найти следующее свободное значение поля nl_pid для данного процесса.

Формат сообщений

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

struct nlmsghdr

{

// Длина сообщения, включая заголовок.

__u32 nlmsg_len;

// Тип содержимого.

__u16 nlmsg_type;

// Дополнительные флаги.

__u16 nlmsg_flags;

// Номер последовательности.

__u32 nlmsg_seq;

// Идентификатор порта отправителя.

__u32 nlmsg_pid;

};

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

Далее следуют поля, зависящие от протокола.

Рис. 11.2. Общий заголовок Netlink

Если сообщений несколько, они могут быть объединены в пакеты, изображенные на рис. 11.3.

Рис. 11.3. Структура Netlink-сообщений

Поле nlmsg_len равно sizeof(nlmsghdr) + длина тела сообщения.

Поле nlmsg_type определяет тип сообщения:

• NLMSG_NOOP — игнорировать сообщение.

• NLMSG_ERROR — сигнал об ошибке. Тело сообщения — структура nlmsgerr.

• NLMSG_DONE — завершить составное сообщение.

Поле nlmsg_flags содержит управляющие флаги:

• NLM_F_REQUEST — это сообщение запроса.

• NLM_F_MULTI — сообщение является частью составного сообщения. В составных сообщениях первый и все последующие заголовки имеют установленный флаг NLM_F_MULTI, за исключением последнего заголовка, который имеет тип NLMSG_DONE.

• NLM_F_ACK — запрос подтверждения успеха.

• NLM_F_ECHO — повторить этот запрос в ответе.

Запросы бывают разных типов, например GET для получения объектов или NEW для создания объектов. Для разных типов запросов определены дополнительные биты флагов.

Для GET:

• NLM_F_ROOT — вернуть полную таблицу вместо одного элемента.

• NLM_F_MATCH — вернуть соответствия критерию, заданному в сообщении.

• NLM_F_ATOMIC — вернуть атомарный слепок указанной таблицы.

• NLM_F_DUMP — NLM_F_ROOT | NLM_F_MATCH.

Для NEW:

• NLM_F_REPLACE — заменить существующий объект.

• NLM_F_EXCL — не заменять существующий объект.

• NLM_F_CREATE — создать объект, если он не существует.

• NLM_F_APPEND — добавить объект в конец списка объектов.

Поле nlmsg_pid показывает источник сообщения, но как мы поняли, nlmsg_pid и PID процесса не всегда равны.

Пример тела сообщения об ошибке:

struct nlmsgerr

{

// Отрицательная errno или 0 для подтверждения.

int error;

// Заголовок сообщения, вызвавший ошибку.

struct nlmsghdr msg;

// За этим заголовком следует содержимое сообщения,

// если не был установлен NETLINK_CAP_ACK

// или ACK не указывает на успех, то есть поле error == 0.

};

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

Рис. 11.4. Атрибут Netlink

Структурирование данных как атрибутов позволяет в будущем расширять конкретные протоколы Netlink без нарушения совместимости.

В Linux заголовки атрибутов представляются структурой:

#include <linux/netlink.h>

struct nlattr

{

// Длина.

__u16 nla_len;

// Тип атрибута и флаги.

__u16 nla_type;

};

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

• N — этот атрибут содержит вложенные атрибуты.

• O — данные атрибута хранятся в сетевом порядке байтов.

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

Сообщения RTNetlink

Сообщения, получаемые через сокеты типа NETLINK_ROUTE, называются сообщениями RTNetlink. Они используются для работы с подсистемами ядра, отвечающими за настройки сети. С помощью NETLINK_ROUTE можно добавлять, удалять и изменять IP-адреса, маршруты, записи в таблицах ARP и FIB.

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

• RTM_NEWLINK, RTM_DELLINK, RTM_GETLINK — создать либо изменить, удалить или получить информацию о конкретном сетевом интерфейсе. Эти сообщения содержат структуру ifinfomsg, за которой следует ряд структур rtattr.

• RTM_NEWADDR, RTM_DELADDR, RTM_GETADDR — добавить либо изменить, удалить или получить информацию об IP-адресе, связанном с интерфейсом. Интерфейс может иметь несколько IP-адресов. Параметр сообщений — структура ifaddrmsg, за которой могут следовать атрибуты маршрутизации rtattr.

• RTM_NEWROUTE, RTM_DELROUTE, RTM_GETROUTE — создать, изменить, удалить или получить информацию о сетевом маршруте. Эти сообщения содержат структуру rtmsg, за которой могут следовать атрибуты.

• RTM_NEWNEIGH, RTM_DELNEIGH, RTM_GETNEIGH — создать либо изменить, удалить или получить информацию о записи таблицы соседей, находящихся в одном коллизионном домене, например запись ARP. Сообщение содержит структуру ndmsg.

• RTM_NEWRULE, RTM_DELRULE, RTM_GETRULE — создать либо изменить, удалить или получить правило маршрутизации. Параметр сообщений — структура rtmsg.

• Сообщения управления трафиком. Параметр сообщений — структура tcmsg:

• RTM_NEWQDISC, RTM_DELQDISC, RTM_GETQDISC — создать либо изменить, удалить или получить дисциплину очередей. За параметром может следовать ряд атрибутов.

• RTM_NEWTCLASS, RTM_DELTCLASS, RTM_GETTCLASS — создать либо изменить, удалить или получить класс трафика.

• RTM_NEWTFILTER, RTM_DELTFILTER, RTM_GETTFILTER — создать либо изменить, удалить или получить информацию о фильтре трафика.

Из них мы рассмотрим только некоторые. За подробностями обращайтесь к man 7 rtnetlink.

Структура сообщений RTM_*LINK показана на рис. 11.5.

Рис. 11.5. Сообщения RTM_*LINK

Структура rtattr — опциональные атрибуты, следующие после начального заголовка:

struct rtattr

{

// Длина опции.

unsigned short rta_len;

// Тип опции.

unsigned short rta_type;

// За этим заголовком следуют данные.

};

Сообщения для работы с адресами показаны на рис. 11.6.

Рис. 11.6. Сообщения типа RTM_*ADDR

На рис. 11.7 показан заголовок сообщений для управления маршрутами в сетевом стеке Linux через сокеты Netlink и получения информации о них. Эти сообщения позволяют приложениям взаимодействовать с таблицами маршрутизации.

Рис. 11.7. Сообщения типа RTM_*ROUTE

Поле типа дистанции, или rtm_scope:

• RT_SCOPE_UNIVERSE — глобальный маршрут.

• RT_SCOPE_SITE — внутренний маршрут в местной автономной системе.

• RT_SCOPE_LINK — маршрут на данном соединении.

• RT_SCOPE_HOST — маршрут на локальном хосте.

• RT_SCOPE_NOWHERE — пункт назначения не существует.

Для понимания того, что такое поле типа маршрута, или rtm_type, укажем его значения:

• RTN_UNSPEC — неизвестный тип маршрута.

• RTN_UNICAST — шлюз или прямой маршрут.

• RTN_LOCAL — маршрут через локальный интерфейс.

• RTN_BROADCAST — широковещательный маршрут, широковещательные сообщения.

• RTN_ANYCAST — широковещательный маршрут, одноадресная рассылка.

• RTN_MULTICAST — многоадресный маршрут.

• RTN_BLACKHOLE — «маршрут», отбрасывающий пакеты.

• RTN_UNREACHABLE — недостижимый пункт назначения.

• RTN_PROHIBIT — маршрут, отклоняющий пакеты.

• RTN_THROW — продолжить поиск маршрута в другой таблице.

• RTN_NAT — правило трансляции сетевых адресов.

• RTN_XRESOLVE — относится к внешнему преобразователю и не реализовано.

• rtm_protocol — происхождение маршрута.

Подробнее о взаимодействии полей типа маршрута и дистанции см. в man.

Макросы для работы с Netlink и RTNetlink

Доступ к потоку байтов должен осуществляться только с помощью стандартных макросов NLMSG_* или API-библиотек, таких как libnetlink. Эти макросы описаны в man 3 rtnetlink и, в некоторых системах, в man 4 rtnetlink.

Рассмотрим основные макросы, определенные в linux/netlink.h:

#include <asm/types.h>

#include <linux/netlink.h>

int NLMSG_ALIGN(size_t len);

int NLMSG_LENGTH(size_t len);

int NLMSG_SPACE(size_t len);

void *NLMSG_DATA(nlmsghdr *nlh);

nlmsghdr *NLMSG_NEXT(nlmsghdr *nlh, int len);

int NLMSG_OK(nlmsghdr *nlh, int len);

int NLMSG_PAYLOAD(nlmsghdr *nlh, int len);

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

• NLMSG_ALIGN() — округлить длину сообщения Netlink в большую сторону, до выровненного. Параметр макроса — длина сообщения.

• NLMSG_LENGTH() — получить выровненную длину для сохранения в поле nlmsg_len структуры nlmsghdr. Использует внутри себя NLMSG_ALIGN(), чтобы получить выровненный размер заголовка nlmsghdr. Принимает размер поля данных, то есть размер сообщения минус размер заголовка.

• NLMSG_SPACE() — получить размер буфера, который заняло бы сообщение Netlink с данными размером len.

• NLMSG_PAYLOAD() — получить длину полезных данных, связанных с nlmsghdr.

• NLMSG_DATA() — получить указатель на начало данных, связанных с заголовком nlmsghdr.

• NLMSG_NEXT() — получить указатель на следующий заголовок nlmsghdr в составном сообщении. Перед вызовом необходимо проверить, не установлен ли флаг NLMSG_DONE в текущем nlmsghdr, потому что макрос не возвращает nullptr в конце. Аргумент len представляет собой lvalue, содержащее оставшуюся длину буфера сообщений. Вызов этого макроса уменьшает его на длину заголовка сообщения.

• NLMSG_OK() — вернуть true, если сообщение Netlink не усечено и корректно.

Для манипулирования атрибутами RTNetlink существует набор макросов:

#include <asm/types.h>

#include <linux/netlink.h>

#include <linux/rtnetlink.h>

#include <sys/socket.h>

int RTA_OK(rtattr *rta, int rtabuflen);

void *RTA_DATA(rtattr *rta);

unsigned int RTA_PAYLOAD(rtattr *rta);

rtattr *RTA_NEXT(rtattr *rta, unsigned int rtabuflen);

unsigned int RTA_LENGTH(unsigned int length);

unsigned int RTA_SPACE(unsigned int length);

Не только сообщения RTNetlink могут содержать атрибуты. В частности, подсистема SOCK_DIAG, рассмотренная ниже, использует атрибуты в том же формате.

Рассмотрим макросы подробнее:

• RTA_OK() — вернуть истину, если rta указывает на допустимый атрибут маршрутизации. Параметр rtabuflen — текущая длина буфера атрибутов. Если макрос возвращает false, считаем, что в сообщении больше нет атрибутов, даже если attrlen отличен от нуля.

• RTA_DATA() — получить указатель на начало данных этого атрибута.

• RTA_PAYLOAD() — получить длину данных этого атрибута.

• RTA_NEXT() — получить следующий атрибут после rta. Вызов этого макроса обновит rtabuflen. Для проверки корректности возвращенного указателя следует использовать макрос RTA_OK.

• RTA_LENGTH() — получить длину, необходимую для len байт данных вместе с заголовком.

• RTA_SPACE() — получить размер буфера, который заняло бы сообщение с данными размером len.

Рассмотрим, как происходит установка MTU устройства через сокет RTNetlink.

Сначала требуется создать новое сообщение:

extern "C"

{

#include <linux/rtnetlink.h>

}

...

const auto iface_index = if_nametoindex(argv[1]);

const unsigned int mtu = std::stoi(argv[2]);

struct

{

// Заголовок.

struct nlmsghdr nh;

// Сообщение.

struct ifinfomsg if_msg;

// Буфер под атрибуты.

char attrbuf[512];

} req = {0};

// Сокет RTNetlink.

const int rtnetlink_sk = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);

if (rtnetlink_sk < 0)

{

// Ошибка.

...

}

// Длина сообщения вместе с заголовком.

req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(ifinfomsg));

// Это сообщение — запрос.

req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;

// Изменить сетевой интерфейс.

req.nh.nlmsg_type = RTM_NEWLINK;

// Семейство адресов интерфейса.

req.if_msg.ifi_family = AF_UNSPEC;

// Его индекс.

req.if_msg.ifi_index = iface_index;

// Флаги интерфейса в ifi_flags, которые должны быть изменены.

// Например, IF_UP.

// Так как флаги не меняются, значение 0.

req.if_msg.ifi_change = 0;

Теперь добавим к нему требуемый атрибут, в который будет записано возвращаемое значение, и выполним запрос:

// Отсюда начинаются атрибуты. Учитывается сдвиг на выравнивание.

rtattr *rta = reinterpret_cast<rtattr *>(reinterpret_cast<char *>(&req) +

NLMSG_ALIGN(req.nh.nlmsg_len));

// Атрибут — MTU.

rta->rta_type = IFLA_MTU;

// Установить длину атрибута: ненулевая длина означает, что атрибут

// существует.

rta->rta_len = RTA_LENGTH(sizeof(unsigned int));

// Добавить эту длину к длине сообщения.

req.nh.nlmsg_len = NLMSG_ALIGN(req.nh.nlmsg_len) + RTA_LENGTH(sizeof(mtu));

std::copy_n(reinterpret_cast<const char*>(&mtu), sizeof(mtu),

static_cast<char*>(RTA_DATA(rta)));

// Отправить запрос.

if (send(rtnetlink_sk, &req, req.nh.nlmsg_len, 0) < 0)

{

...

}

Запустив код, увидим, что он изменяет MTU:

➭ ip a show lo

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65535 qdisc noqueue state UNKNOWN group default qlen 1000

link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

inet 127.0.0.1/8 scope host lo

valid_lft forever preferred_lft forever

➭ sudo build/bin/b01-ch11-netlink-mtu lo 1230

➭ ip a show lo

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 1230 qdisc noqueue state UNKNOWN group default qlen 1000

Работать с RTNetlink напрямую достаточно сложно: надо правильно выставлять длины, учитывать выравнивание, затем отдельно читать ответ для проверки корректности запроса, что не показано в примере.

Сокеты Netlink типа SOCK_DIAG

Подсистема SOCK_DIAG предоставляет механизм получения от ядра информации о сокетах различных семейств. Через нее возможно запросить список сокетов или получить информацию о конкретном сокете.

Подсистема SOCK_DIAG была введена с целью предоставления информации для ss без использования ProcFS.

NETLINK_INET_DIAG был введен в Linux 2.6 и поддерживал только сокеты AF_INET и AF_INET6.

В Linux 3.3 он был переименован в NETLINK_SOCK_DIAG и была добавлена поддержка сокетов AF_UNIX.

При запросе списка можно указать фильтры, применяемые ядром для выбора подмножества сокетов. Имеется возможность фильтровать сокеты по состоянию: подключен, прослушивается и т.д.

Для получения информации о конкретном сокете можно указать, какие данные требуется получить.

Внимание! SOCK_DIAG сообщает только о тех сокетах, у которых есть адрес, то есть для которых был вызван bind(), connect() либо адрес был назначен автоматически.

Доступ к этой подсистеме возможен через сокеты Netlink:

#include <sys/socket.h>

#include <linux/sock_diag.h>

// Для сокетов домена Unix.

#include <linux/unix_diag.h>

// Для сокетов IPv4 и IPv6.

#include <linux/inet_diag.h>

int diag_socket = socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG);

Запрос

Чтобы получить информацию от системы, требуется сделать запрос, начинающийся с Netlink-заголовка nlmsghdr со следующими параметрами:

• Поле nlmsg_type установлено в SOCK_DIAG_BY_FAMILY.

• В nlmsg_flags установлен флаг NLM_F_DUMP, если запрашивается список сокетов. Если флаг не установлен, ответ на запрос будет включать информацию об отдельном сокете.

За основным заголовком следует заголовок запроса sock_diag:

struct sock_diag_req

{

// Семейство адресов, например AF_INET.

__u8 sdiag_family;

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

// Например, IPPROTO_TCP.

__u8 sdiag_protocol;

};

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

Заголовки Unix-сокетов

Структура либо определяет запрос списка сокетов, состояние которых указано флагами в поле udiag_states, либо идентифицирует конкретный сокет:

struct unix_diag_req

{

// Семейство: AF_UNIX.

__u8 sdiag_family;

// Всегда 0.

__u8 sdiag_protocol;

// Заполнение. Всегда 0.

__u16 pad;

// Используется для запроса групп сокетов.

// 1 << TCP_ESTABLISHED — для получения сокетов, через которые установлено

// соединение.

// 1 << TCP_LISTEN — для прослушивающих сокетов.

__u32 udiag_states;

// Номер inode в файловой системе, при запросе индивидуального сокета.

// Игнорируется при запросе списка.

__u32 udiag_ino;

// Флаги, определяющие, какую информацию получить.

__u32 udiag_show;

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

// определения индивидуального сокета.

// Игнорируются при запросе списка.

// Если установлены в -1, также игнорируются.

__u32 udiag_cookie[2];

};

Информацию, которую необходимо получить, определяют флаги данной структуры, установленные в поле udiag_show:

• UDIAG_SHOW_NAME — путь к файлу сокета.

• UDIAG_SHOW_VFS — устройство и inode, что полезно для групп сокетов.

• UDIAG_SHOW_PEER — inode «удаленного» сокета, подключенного к указанному.

• UDIAG_SHOW_ICONS — inode сокетов, на которых вызвана connect(), но еще не вызвана accept().

• UDIAG_SHOW_RQLEN — ответ содержит два поля:

• Для прослушивающих сокетов — количество ожидающих соединений. Для подключенных сокетов — количество данных в очереди приема.

• Длина очереди, которая была задана при вызове listen(), или объем доступной памяти в буфере передачи.

• UDIAG_SHOW_MEMINFO — массив 32-битных значений, содержащих информацию о сокете. Подробно описан в man 7 sock_diag.

• UNIX_DIAG_SHUTDOWN — биты состояния SHUT_RD или SHUT_WR для сокета.

Заголовки Internet-сокетов

Общий заголовок для типа SOCK_DIAG, а также поля запроса для INET_DIAG показаны на рис. 11.8.

Рис. 11.8. Заголовок для запроса типа SOCK_DIAG и часть запроса INET_DIAG

Структура аналогична той, которая используется для запроса Unix-сокетов, но фильтры и адресация Internet-сокетов отличаются:

struct inet_diag_req_v2

{

// Семейство: AF_INET, AF_INET6.

__u8 sdiag_family;

// Протокол: IPPROTO_TCP, IPPROTO_UDP, IPPROTO_UDPLITE.

__u8 sdiag_protocol;

// Флаги, которые задают дополнительную информацию для получения.

__u8 idiag_ext;

// Заполнение. Всегда 0.

__u8 pad;

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

// Будут получены только сокеты, которые подходят под фильтр.

// Когда запрашивается информация о конкретном сокете, поле игнорируется.

__u32 idiag_states;

// Структура, идентифицирующая конкретный интернет-сокет.

struct inet_diag_sockid id;

};

Структура, идентифицирующая сокет, содержит адреса, порты, интерфейс, связанные с сокетом:

struct inet_diag_sockid

{

// Порт источника.

__be16 idiag_sport;

// Порт назначения.

__be16 idiag_dport;

// Адрес источника.

__be32 idiag_src[4];

// Адрес назначения.

__be32 idiag_dst[4];

// Номер связанного интерфейса.

// Если сокет не привязан к интерфейсу, имеет значение 0.

__u32 idiag_if;

// Игнорируются при запросе списка.

// Если установлены в -1, также игнорируются.

__u32 idiag_cookie[2];

};

Внимание! Поля структуры inet_diag_sockid должны быть записаны в сетевом порядке байтов.

Флаги для получения расширенной информации содержатся в поле idiag_ext:

• INET_DIAG_TOS — вернется байт, содержащий значение TOS.

• INET_DIAG_TCLASS — для IPv6-сокета вернется байт, содержащий TClass. Для сокетов в состоянии LISTEN и CLOSE за этим байтом следует атрибут INET_DIAG_SKV6ONLY с байтом, указывающим, является сокет только IPv6 или нет.

• INET_DIAG_MEMINFO — возвращает структуру inet_diag_meminfo, содержащую:

• idiag_rmem — размер данных в очереди приема.

• idiag_wmem — размер неотправленных данных в очереди передачи TCP.

• idiag_fmem — размер памяти, запланированный TCP для будущего использования.

• idiag_tmem — размер данных в очереди передачи.

• INET_DIAG_SKMEMINFO — массив четырехбайтных значений, содержащих информацию о памяти сокета. Подробнее см. в man.

• INET_DIAG_INFO — для сокетов TCP возвращает структуру tcp_info. Для сокетов другого типа будет возвращена другая структура.

• INET_DIAG_CONG — возвращает строку, которая содержит название используемого алгоритма управления перегрузкой TCP.

• INET_DIAG_SHUTDOWN — для сокетов TCP возвращает байт, содержащий биты состояния SHUT_RD, SHUT_WR, SHUT_RDWR или 0.

• INET_DIAG_VEGASINFO — для сокетов TCP возвращает структуру, которая содержит информацию по алгоритму Vegas, используемому для контроля перегрузки.

Некоторые расширения пока нельзя запросить через Sock_diag, потому что поле флагов имеет размер 1 байт и флагов может быть всего восемь.

Внимание! Описание значения флагов отличается от аналогичного для Unix-сокетов. Значения начинаются с единицы. Нулевое значение — INET_DIAG_NONE. Это необходимо учитывать при установке флагов, вычитая из бита флага единицу. Проверка же флагов производится с использованием значений без вычета единицы.

Поле idiag_states устанавливается аналогично udiag_states для Unix-сокетов. Для TCP флаги могут принимать следующие значения, которые соответствуют различным состояниям протокола:

• TCP_ESTABLISHED.

• TCP_SYN_SENT.

• TCP_FIN_WAIT1.

• TCP_FIN_WAIT2.

• TCP_CLOSE_WAIT.

• TCP_LAST_ACK.

• TCP_CLOSING.

Поля *_cookie содержат уникальный для системы 64-битный идентификатор сокета.

Например, утилита ss выводит его после "sk:" и его значение — 4046:

➭ ss -e -4 --tos|head -2

Netid State Recv-Q Send-Q Local Address:Port  Peer Address:Port   Process

udp   ESTAB 0      0      192.168.2.13:54654  91.103.66.241:https

ino:6525419 sk:4046 cgroup:/user.slice/user-1000...scope <-> tos:0

class_id:0

Ответ

В ответ подсистема вернет массив сообщений Netlink, предваряемых заголовками. За сообщениями могут следовать атрибуты, работа с которыми осуществляется через макросы RTNetlink. Типы этих атрибутов для Unix-сокетов обозначены макросами, например UNIX_DIAG_NAME. Следовательно, достаточно убрать «_SHOW» из макроса запроса, а UDIAG заменить на UNIX_DIAG.

Заголовок для Unix-сокетов:

struct unix_diag_msg

{

__u8 udiag_family;

// SOCK_PACKET, SOCK_STREAM или SOCK_SEQPACKET.

__u8 udiag_type;

// TCP_LISTEN или TCP_ESTABLISHED.

__u8 udiag_state;

__u8 pad;

// Номер inode.

__u32 udiag_ino;

__u32 udiag_cookie[2];

};

Для Internet-сокетов макросы ответов называются так же, как и макросы запросов, то есть на запрос INET_DIAG_TOS придет атрибут с типом INET_DIAG_TOS. А заголовок сообщения описывается следующей структурой:

struct inet_diag_msg

{

// Семейство.

__u8 idiag_family;

// Флаг состояния как в запросе.

__u8 idiag_state;

// Тип активного таймера для TCP-сокетов.

__u8 idiag_timer;

// Количество попыток повторной передачи.

__u8 idiag_retrans;

struct inet_diag_sockid id;

// Количество миллисекунд до истечения таймера для TCP-сокетов.

__u32 idiag_expires;

// Количество ожидающих соединений для прослушивающих сокетов.

// Или размер данных в очереди чтения.

__u32 idiag_rqueue;

// Для прослушивающих сокетов — размер очереди,

// указанный при вызове listen().

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

__u32 idiag_wqueue;

// Идентификатор пользователя, который владеет сокетом.

__u32 idiag_uid;

// Номер inode сокета в файловой системе.

__u32 idiag_inode;

};

Структуры, возвращаемые при указании флага INET_DIAG_INFO, такие как tcp_info, определены в заголовочных файлах ядра. Например, последняя определена в linux/tcp.h и содержит множество внутренней информации: метрики, размеры окна, количество отправленных пакетов, которые еще не были подтверждены, и т.п.

Внимание! Структура tcp_info определена и в файле netinet/tcp.h. Это разные структуры. Они не взаимозаменяемы. Именно в данном случае нужно использовать структуру из заголовочного файла в каталоге linux, потому что подсистема специфична для Linux и пользуется структурами ядра.

Получение списка Internet-сокетов

Рассмотрим пример, в котором реализован просмотр TCP-сокетов в системе, то есть часть функциональности утилиты ss. Выполним реализацию, используя подсистему sock_diag.

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

extern "C"

{

#include <unistd.h>

#include <sys/socket.h>

#include <asm/types.h>

#include <net/if.h>

#include <linux/netlink.h>

#include <linux/rtnetlink.h>

#include <linux/sock_diag.h>

#include <linux/inet_diag.h>

// Использовать специфичный для Linux заголовочный файл:

#include <linux/tcp.h>

#include <arpa/inet.h>

#include <netinet/in.h>

// Этот заголовочный файл нельзя использовать!

// #include <netinet/tcp.h>

}

...

// Удобная структура, описывающая заголовок для запроса sock_diag

// об интернет-сокетах.

struct netlink_request

{

struct nlmsghdr nlh;

struct inet_diag_req_v2 irh;

};

В функции отправки запроса еще раз обратите внимание на то, как устанавливаются флаги структуры.

Для получения списка IPv6-сокетов или сокетов разных транспортных протоколов требуются отдельные запросы с другими параметрами.

В коде ниже будут получены только TCP-сокеты IPv4. Сначала инициализируем структуры запроса:

void send_query(int fd)

{

// "Адрес" Netlink.

sockaddr_nl nladdr =

{

.nl_family = AF_NETLINK

};

// Структура запроса.

netlink_request req =

{

.nlh =

{

.nlmsg_len = sizeof(req),

// Это запрос sock_diag по семейству адресов.

.nlmsg_type = SOCK_DIAG_BY_FAMILY,

// NLM_F_REQUEST говорит о том, что это запрос.

// NLM_F_DUMP говорит о том, что запрашивается список.

.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP

},

.irh =

{

// Сокеты типа AF_INET.

.sdiag_family = AF_INET,

// Интересуют TCP-сокеты.

.sdiag_protocol = IPPROTO_TCP,

// Дополнительно запросить TOS и структуру tcp_info.

.idiag_ext = 1 << (INET_DIAG_TOS — 1) | 1 << (INET_DIAG_INFO — 1),

.pad = 0,

// Сейчас запрашиваются сокеты в любом состоянии,

// что эквивалентно комбинации флагов:

// 1 << TCP_ESTABLISHED | 1 << TCP_SYN_SENT | \

// 1 << TCP_FIN_WAIT1| 1 << TCP_FIN_WAIT2 | 1 << TCP_CLOSE_WAIT \

// 1 << TCP_LAST_ACK | 1 << TCP_CLOSING

.idiag_states = static_cast<__u32>(-1),

.id = {0}

}

};

Создадим новое сообщение и выполним запрос через send_msg():

// Данные сообщения.

struct iovec iov = { .iov_base = &req, .iov_len = sizeof(req) };

// Сообщение.

struct msghdr msg =

{

.msg_name = &nladdr,

.msg_namelen = sizeof(nladdr),

.msg_iov = &iov,

.msg_iovlen = 1,

.msg_controllen = 0,

.msg_flags = 0

};

// Отправка сообщения, в случае EINTR ее следует повторить.

while (true)

{

if (sendmsg(fd, &msg, 0) < 0)

{

if (EINTR == errno) continue;

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

"sendmsg");

}

break;

}

}

Функция print_response() получает ответы и вызывает функцию, выводящую их на печать.

Сначала прочитаем ответ через recvmsg():

void print_responses(int fd)

{

long buf[4096];

sockaddr_nl nladdr;

// Указывает на буфер с данными принимаемого сообщения.

iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };

int flags = 0;

while (true)

{

// Сообщение.

msghdr msg =

{

.msg_name = &nladdr,

.msg_namelen = sizeof(nladdr),

.msg_iov = &iov,

.msg_iovlen = 1

};

ssize_t ret = recvmsg(fd, &msg, flags);

if (ret < 0)

{

// Как всегда, проверяем EINTR.

if (EINTR == errno) continue;

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

"recvmsg");

}

if (0 == ret) return;

Выполним несколько проверок:

if (nladdr.nl_family != AF_NETLINK)

{

throw std::logic_error("nl_family != AF_NETLINK");

}

const nlmsghdr *h = reinterpret_cast<const nlmsghdr *>(buf);

// Проверка на корректность обязательна,

// ведь транспорт в Netlink ненадежный.

if (!NLMSG_OK(h, ret))

{

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

"not NLMSG_OK");

}

Далее в цикле будем выполнять разбор ответов:

for (; NLMSG_OK(h, ret); h = NLMSG_NEXT(h, ret))

{

// Последнее сообщение в серии.

if (NLMSG_DONE == h->nlmsg_type) return;

// Сообщение будет содержать ошибку, которую вернула подсистема.

if (NLMSG_ERROR == h->nlmsg_type)

{

const nlmsgerr *err = static_cast<const nlmsgerr*>(

NLMSG_DATA(h));

// Проверить длину.

if (h->nlmsg_len < NLMSG_LENGTH(sizeof(*err)))

{

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

"NLMSG_ERROR");

}

else

{

// Можно установить errno, но код ошибки,

// возвращаемый подсистемой, меньше нуля.

errno = -err->error;

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

"NLMSG_ERROR");

}

}

// Теперь проверим, что это нужный ответ.

if (h->nlmsg_type != SOCK_DIAG_BY_FAMILY)

{

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

"unexpected nlmsg_type");

}

// Наконец, можем выполнить преобразование и вызвать функцию

// печати ответа.

print_diag(static_cast<const inet_diag_msg*>(NLMSG_DATA(h)),

h->nlmsg_len);

}

}

}

Функция печати ответа сначала выводит поля структуры inet_diag_msg:

void print_diag(const inet_diag_msg *diag, unsigned int len)

{

// Базовые проверки.

if (len < NLMSG_LENGTH(sizeof(*diag)))

{

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

"short response");

}

if (diag->idiag_family != AF_INET && diag->idiag_family != AF_INET6)

{

throw std::logic_error("unexpected protocol family");

}

std::string src_addr(INET_ADDRSTRLEN, 0);

std::string dst_addr(INET_ADDRSTRLEN, 0);

inet_ntop(diag->idiag_family, diag->id.idiag_src, &src_addr[0],

src_addr.size());

inet_ntop(diag->idiag_family, diag->id.idiag_dst, &dst_addr[0],

src_addr.size());

// Вывод адресов. Помним, что все поля — в сетевом порядке байтов.

std::cout

<< src_addr << ":" << ntohs(diag->id.idiag_sport)

<< " -> "

<< dst_addr << ":" << ntohs(diag->id.idiag_dport);

Если сокет привязан к интерфейсу, также выводится имя этого интерфейса:

// Если номер интерфейса не является нулевым, сокет привязан к интерфейсу.

if (diag->id.idiag_if)

{

// Получим название интерфейса, пользуясь уже изученной функцией.

std::unique_ptr<struct if_nameindex, decltype(&if_freenameindex)>

if_ni(if_nameindex(), &if_freenameindex);

if (nullptr == if_ni)

{

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

"if_nameindex");

}

std::string if_name;

for (struct if_nameindex *i = if_ni.get();

!(0 == i->if_index && nullptr == i->if_name); ++i)

{

if (i->if_index == diag->id.idiag_if)

{

if_name = i->if_name;

break;

}

}

std::cout << " [" << if_name << "]";

}

После этого при наличии атрибутов функция осуществляет их разбор и печать. Выполним эти операции в цикле. Сначала проверим ToS:

std::cout << ":\n";

unsigned int rta_len = len — NLMSG_LENGTH(sizeof(*diag));

tcp_info ti;

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

for (const rtattr *attr = reinterpret_cast<const rtattr *>(diag + 1);

RTA_OK(attr, rta_len); attr = RTA_NEXT(attr, rta_len))

{

switch (attr->rta_type)

{

case INET_DIAG_TOS:

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

if (RTA_PAYLOAD(attr) != 1)

throw std::logic_error("TOS length error");

std::cout

<< "  TOS: "

<< +*static_cast<const uint8_t*>(RTA_DATA(attr))

<< "\n";

break;

Выведем диагностическую информацию, такую как RTT и число недоставленных пакетов:

case INET_DIAG_INFO:

{

auto data_size = RTA_PAYLOAD(attr);

// Эта проверка выявит использование неправильной

// структуры tcp_info.

if (data_size != sizeof(tcp_info))

throw std::logic_error("tcp_info length error, check if "

"you use correct header "

"(linux/tcp.h)");

tcp_info ti;

auto data = static_cast<const uint8_t*>(RTA_DATA(attr));

std::copy(data, data + data_size,

reinterpret_cast<uint8_t*>(&ti));

std::cout

<< "  Lost packets: " << ti.tcpi_lost << "\n"

<< "  Retransmits: " << +ti.tcpi_retransmits << "\n"

<< "  RTT: " << ti.tcpi_rtt << "\n";

break;

}

Часть атрибутов не была запрошена, но они в любом случае придут, например, контрольная группа или флаги вызова shutdown(), если он был вызван:

// Некоторые атрибуты не запрашивались, но они все равно приходят.

case INET_DIAG_SHUTDOWN:

if (RTA_PAYLOAD(attr) != 1)

throw std::logic_error("Shutdown flags length error");

std::cout

<< "  Shutdown flags: "

<< + *static_cast<uint8_t*>(RTA_DATA(attr))

<< "\n";

break;

case INET_DIAG_CGROUP_ID:

if (RTA_PAYLOAD(attr) != sizeof(uint64_t))

throw std::logic_error("CGroup length error");

std::cout

<< "  Control group: "

<< *static_cast<const uint64_t*>(RTA_DATA(attr))

<< "\n";

break;

case INET_DIAG_SOCKOPT:

if (RTA_PAYLOAD(attr) != sizeof(inet_diag_sockopt))

throw std::logic_error("Sockopt flags length error");

// Можно использовать поля структуры inet_diag_sockopt.

std::cout << "  SOCKOPT\n";

break;

default:

std::cerr

<< "  Unknown attribute: "

<< "0x" << std::hex << attr->rta_type

<< "\n";

}

}

Функция main() создает сокет Netlink, вызывает отправку запроса и получение ответов:

int main()

{

try

{

int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG);

if (fd < 0)

{

throw std::system_error(errno, std::generic_category(), "socket");

}

try

{

send_query(fd);

print_responses(fd);

close(fd);

}

catch(...)

{

close(fd);

throw;

}

}

catch (const std::exception &e)

{

std::cerr << e.what() << std::endl;

return EXIT_FAILURE;

}

catch (...)

{

std::cerr << "Unknown exception!" << std::endl;

return EXIT_FAILURE;

}

return EXIT_SUCCESS;

}

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

➭ build/bin/b01-ch11-sock_diag

127.0.0.53:53 -> 0.0.0.0:0 [lo]:

Shutdown flags: 0

TOS: 0

Control group: 2800

SOCKOPT

Lost packets: 0

Retransmits: 0

RTT: 0

...

0.0.0.0:5355 -> 0.0.0.0:0:

Shutdown flags: 0

TOS: 0

Control group: 2800

SOCKOPT

Lost packets: 0

Retransmits: 0

RTT: 0

192.168.2.13:49968 -> 185.211.245.141:8434:

Shutdown flags: 0

TOS: 0

Control group: 55638

SOCKOPT

Lost packets: 0

Retransmits: 0

RTT: 11196

...

Вывод утилиты ss аналогичен:

➭ ss -4 -at

State   Recv-Q   Send-Q      Local Address:Port         Peer Address:Port

LISTEN       0     4096    127.0.0.53%lo:domain                 0.0.0.0:*

LISTEN       0     4096         127.0.0.1:37265                 0.0.0.0:*

LISTEN       0     4096       127.0.0.54:domain                 0.0.0.0:*

LISTEN       0     4096           0.0.0.0:llmnr                 0.0.0.0:*

ESTAB        0        0      192.168.2.13:49968      185.211.245.141:8434

ESTAB        0        0      192.168.2.13:38438        45.95.201.21:11443

ESTAB        0        0      192.168.2.13:38218       209.85.233.94:https

...

Аналоги сокетов Netlink

Linux — не единственная система, в которой реализован обмен между ядром и приложением через сокеты. Хотя сокеты Netlink и специфичны для Linux, но в некоторых других операционных системах разработчики пошли по аналогичному пути.

В NetBSD и OpenBSD реализованы сокеты домена PF_ROUTE, которые используются для управления таблицей маршрутизации. Сокеты поддерживают команды RTM_ADD, RTM_DELETE и подобные, что позволяет управлять маршрутами. Адреса, связанные с сообщениями о маршрутизации, передаются как двоичные данные в сообщении.

Также в BSD есть сокеты домена PF_SYSTEM. В частности, API управления KEXTs55, то есть драйверами, в MacOS X реализовано через эти сокеты.

Для FreeBSD реализованы сокеты AF_NETLINK, как в Linux, поэтому данный интерфейс постепенно становится переносимым.

Во FreeBSD данные сокеты перенесены в 2021 году, начиная с версии 13, и сейчас они могут быть еще не доработаны.

Например, получать в MacOS X события интерфейсов можно так:

extern "C"

{

#include <sys/socket.h>

#include <sys/ioctl.h>

#include <sys/types.h>

#include <net/if.h>

#include <sys/kern_event.h>

}

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

{

// Сокет PF_SYSTEM создается для прослушивания событий.

int s = socket(PF_SYSTEM, SOCK_RAW, SYSPROTO_EVENT);

// Установим фильтр на события.

kev_request key;

key.vendor_code = KEV_VENDOR_APPLE;

key.kev_class = KEV_NETWORK_CLASS;

key.kev_subclass = KEV_ANY_SUBCLASS;

int code = ioctl(s, SIOCSKEVFILT, &key);

kern_event_msg msg;

// Цикл получения событий.

while (true)

{

// Обратите внимание, что здесь используется обычный recv().

code = recv(s, &msg, sizeof(msg), 0);

// Реакция на разные типы событий.

switch (msg.event_code)

{

case KEV_DL_IF_DETACHED:

// Интерфейс отсоединен.

break;

case KEV_DL_IF_ATTACHED:

// Интерфейс подсоединен.

break;

case KEV_DL_LINK_OFF:

// Интерфейс отключен.

break;

case KEV_DL_LINK_ON:

// Интерфейс подключен.

break;

}

}

return EXIT_SUCCESS;

}

Резюме

Мы рассмотрели более современный интерфейс для управления сокетами, интерфейсами, устройствами и параметрами ядра, который пришел на замену вызовам ioctl, — сокеты домена AF_NETLINK.

Команда ip в Linux активно использует подсистему Netlink. И это важное отличие от устаревшей команды ifconfig.

API Netlink включает сокетный интерфейс, набор макросов и библиотеки. В этой главе мы рассмотрели первые два компонента.

Адреса Netlink представлены структурой sockaddr_nl, которая содержит уникальный идентификатор и список групп многоадресной рассылки.

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

Сообщения разных типов имеют разную структуру. Мы рассмотрели NETLINK_ROUTE- и SOCK_DIAG-подсистемы.

Сокеты типа NETLINK_ROUTE предоставляют интерфейс RTNetlink. Он используется для работы с таблицами маршрутизации ядра, позволяя их получать и менять.

Работать с RTNetlink напрямую достаточно сложно: требуется правильно выставлять длины, учитывать выравнивание и отдельно читать ответ для проверки корректности запроса.

Подсистема SOCK_DIAG требуется для получения от ядра информации о сокетах различных семейств.

Через нее можно запросить список сокетов, указать фильтры для подмножества сокетов или получить информацию о конкретном сокете. Прежде всего эта подсистема дает возможность запрашивать данные интернет- и Unix-сокетов.

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

В дальнейшем, с развитием технологий и сетевых стандартов, важность и использование сокетов Netlink продолжит расти, что делает их изучение актуальным. Интерфейсы, подобные сокетам Netlink, появляются не только в Linux, но и в других ОС.

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

1. Что представляют собой сокеты Netlink?

2. Когда нужно использовать сокеты Netlink?

3. Почему сокеты Netlink пришли на замену ioctl?

4. Каково основное требование к идентификатору сокета Netlink?

5. Какой порядок байтов используется при записи данных в структуры сокета Netlink?

6. Зачем нужно перечисление групп многоадресной рассылки в адресе типа sockaddr_nl?

7. Какие типы сообщений могут быть отправлены через сокеты Netlink?

8. Что собой представляет сообщение Netlink?

9. Надежный ли транспорт предоставляет Netlink? Как обеспечивается надежность?

10. Что такое сообщения RTNetlink? Для чего они используются?

11. Как отправить сообщение в сокет RTNetlink?

12. Как интерпретировать данные ответа, пришедшие через сокет RTNetlink?

13. Как обрабатывается событие подключения или отключения интерфейса в коде, работающем с Netlink?

14. О каких типах сокетов предоставляет информацию подсистема SOCK_DIAG?

15. О каких сокетах не сообщит подсистема SOCK_DIAG?

16. Какие аналоги существуют у сокетов Netlink в других ОС?

17. Доработайте пример из главы, чтобы он показывал процесс, использующий сокет. Аналог ключа -t утилиты ss.

55 https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/NKEConceptual/control/control.html

Глава 12. Библиотеки Netlink

Надежное и прозрачное ПО обычно не отвечает интересам проектировщика.

Никлаус Вирт, «A Digital Contrarian Retires», 1999

Введение

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

В этой главе мы подробно рассмотрим функциональные возможности и API основных библиотек для работы c Netlink, а также их применение в разработке сетевых приложений.

Начнем с Libnl — достаточно большого пакета, далее рассмотрим более простые альтернативы.. Наконец, в завершение главы перейдем к пакетам Python.

Пакет Libnl

Пакет libnl56 — набор библиотек, покрывающий большую часть API Netlink. Он включает несколько библиотек, в которых реализованы подмножества API:

• libnl, или libnl-core, — основная библиотека, в которой реализованы построение и анализ сообщений, работа с сокетами Netlink, отправка и получение данных. От нее зависят все остальные библиотеки пакета.

• libnl-route — API RTNetlink для семейства адресов NETLINK_ROUTE.

• libnl-genl — API общего Netlink-протокола, расширенной версии протокола Netlink.

• libnl-nf — настройка сетевых фильтров и интерфейсов мониторинга.

• libnl-idiag — поддержка сокетов INET_DIAG.

Структура пакета libnl показана на рис. 12.1.

Кратко рассмотрим, что собой представляет API библиотеки. Узнать о нем подробнее можно из руководства57.

Рис. 12.1. Структура пакета libnl

Создание, настройка и освобождение сокета

В первую очередь для создания и разрушения сокета, используемого остальными функциями пакета, требуется API. Он реализован в библиотеке libnl-core:

#include <netlink/socket.h>

nl_sock *nl_socket_alloc();

void nl_socket_free(nl_sock *sk);

В структуре nl_sock библиотека хранит, например, счетчик порядковых номеров, обработка которых выполняется автоматически.

Счетчиком также можно управлять, используя функции библиотеки:

#include <netlink/socket.h>

unsigned int nl_socket_use_seq(nl_sock *sk);

void nl_socket_disable_seq_check(nl_sock *sk);

Функция nl_socket_use_seq() вернет текущее значение счетчика и увеличит его на единицу. А функция nl_socket_disable_seq_check() отключает контроль последовательности.

Для получения сообщений, приходящих в сокет, можно установить обработчик, используя функцию nl_socket_set_cb():

typedef int (*nl_recvmsg_msg_cb_t)(nl_msg *msg, void *arg);

typedef int (*nl_recvmsg_err_cb_t)(sockaddr_nl *nla, nlmsgerr *nlerr,

void *arg);

enum nl_cb_type

{

// Сообщение корректно.

NL_CB_DUMP_INTR,

__NL_CB_TYPE_MAX,

};

struct nl_cb

{

// Массив обработчиков по типам сообщений

nl_recvmsg_msg_cb_t cb_set[NL_CB_TYPE_MAX + 1];

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

void *cb_args[NL_CB_TYPE_MAX + 1];

// Обработчики ошибок.

nl_recvmsg_err_cb_t cb_err;

// Пользовательские аргументы обработчиков.

void *cb_err_arg;

// Может использоваться для замены nl_recvmsgs() функцией пользователя.

int (*cb_recvmsgs_ow)(nl_sock *, nl_cb *);

// Пользовательская замена nl_recv().

// Должна возвращать число прочитанных и размещенных в буфере октетов.

int (*cb_recv_ow)(nl_sock *, sockaddr_nl *, unsigned char **, ucred **);

// Пользовательская замена nl_send().

int (*cb_send_ow)(nl_sock *, nl_msg *);

// Счетчик ссылок на экземпляр структуры.

int cb_refcnt;

// Показывает, что обработчик активен.

enum nl_cb_type cb_active;

};

nl_cb *nl_socket_get_cb(const nl_sock *sk);

void nl_socket_set_cb(nl_sock *sk, struct nl_cb *cb);

Функция nl_socket_get_cb() позволяет получить установленный обработчик.

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

Отправка и получение сообщений

Простейший вариант отправки данных реализуют следующие функции:

int nl_send_auto(nl_sock *sk, nl_msg *msg);

int nl_send_sync(nl_sock *sk, nl_msg *msg);

Первая завершает и отправляет сообщение Netlink, а вторая после отправки дожидается подтверждения ACK и возвращает управление, то есть вызывает функцию nl_send_auto(), а затем nl_wait_for_ack().

При использовании функции nl_send_auto() порядковый номер заполняется автоматически и сопоставляется, когда получен ответ. Это поведение можно отключить, что полезно, например, когда сокет используется для получения уведомлений.

Функция nl_send_auto() отвечает только за финализацию и отправку, буквально вызывая две функции:

int nl_send_auto(struct nl_sock *sk, struct nl_msg *msg)

{

nl_complete_msg(sk, msg);

return nl_send(sk, msg);

}

Функция nl_send() вызывает функцию nl_send_iovec():

int nl_send_iovec(nl_sock *sk, nl_msg *msg, iovec *iov, unsigned iovlen);

А реальная отправка производится функцией nl_sendmsg(), которая использует системный вызов sendmsg():

int nl_sendmsg(nl_sock *sk, nl_msg *msg, msghdr *hdr);

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

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

int nl_send_simple(nl_sock *sk, int type, int flags, void *buf, size_t size);

Принять сообщения можно, используя следующую функцию:

int nl_recvmsgs_default(nl_sock *sk);

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

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

int nl_recvmsgs(nl_sock *sk, nl_cb *cb);

int nl_recvmsgs_report(nl_sock *sk, nl_cb *cb);

Функция nl_recvmsgs() в случае успеха возвращает 0, а функция nl_recvmsgs_report() — количество принятых и обработанных сообщений. В остальном эти функции идентичны.

Более низкоуровневая функция nl_recv() требуется, когда разработчик самостоятельно управляет буферами:

int nl_recv(nl_sock *sk, sockaddr_nl *nla, unsigned char **buf,

ucred **creds);

Параметры данной функции:

• sk — сокет Netlink.

• nla — обязательный указатель на адрес пира, который установит ядро.

• buf — указатель на буфер приема.

• creds — указатель для приема учетных данных.

Функция вернет количество прочитанных байтов или отрицательный код ошибки.

Также можно использовать функцию nl_pickup(), чтобы прочитать сообщение и не удалять его:

int nl_pickup(nl_sock *sk,

int (*parser)(nl_cache_ops *, sockaddr_nl *, nlmsghdr *,

nl_parser_param *),

nl_object **result);

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

• sk — сокет Netlink.

• parser — обработчик для принятого ответа.

• result — указатель результата для возврата разобранного ответа.

Функция возвращает 0 в случае успеха и отрицательный код при ошибке.

Конструирование и разбор сообщений

Сообщение в библиотеке представлено структурой nl_msg, которая хранит их в своем буфере:

struct nl_msg

{

int nm_protocol;

int nm_flags;

struct sockaddr_nl nm_src;

struct sockaddr_nl nm_dst;

struct ucred nm_creds;

// Заголовок сообщения Netlink.

struct nlmsghdr *nm_nlh;

size_t nm_size;

int nm_refcnt;

};

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

#include <netlink/msg.h>

// Выделить память для сообщения по умолчанию.

nl_msg *nlmsg_alloc();

// Выделить память заданного размера.

nl_msg *nlmsg_alloc_size(size_t max);

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

// и скопировать в это сообщение заголовок.

nl_msg *nlmsg_inherit(nlmsghdr *hdr);

// То же, что и nlmsg_inherit(), но принимает два поля заголовка.

nl_msg *nlmsg_alloc_simple(int nlmsg_type, int flags);

Функция nlmsg_alloc() и аналогичные по умолчанию выделяют буфер, равный размеру страницы. Изменить этот размер можно, используя функцию nlmsg_set_default_size():

void nlmsg_set_default_size(size_t);

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

Максимальный размер сообщения необходим для того, чтобы не перераспределять буфер, например, используя realloc(). Любое такое перераспределение должно выполняться только явно, так как оно приводит к недействительности всех указателей на сообщение и его элементы.

Для перераспределения буфера существует функция nlmsg_expand(). Ее мы рассматривать не будем.

Очевидно, что если для выделения не использовалась функция nlmsg_alloc_simple() или nlmsg_inherit(), то после того, как память выделена, в сообщение необходимо добавить заголовок, используя следующую функцию:

nlmsghdr *nlmsg_put(nl_msg *msg, uint32_t port, uint32_t seqnr,

int nlmsg_type, int payload, int nlmsg_flags);

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

• msg — буфер сообщения, в которое будет добавлен заголовок.

• port — порт, назначаемый сокету. Может быть установлен в значение NL_AUTO_PORT, тогда локальный порт, назначенный сокету, будет использоваться в качестве исходного порта.

• seqnr — номер последовательности. Может быть установлен в NL_AUTO_SEQ, чтобы указать, что следующий номер должен использоваться автоматически.

• nlmsg_type — тип сообщения.

• payload — данные сообщения.

• nlmsg_flags — флаги.

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

Функция nlmsg_reserve() резервирует место в конце сообщения:

void *nlmsg_reserve(nl_msg *msg, size_t len, int pad);

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

• len — количество выделяемых байт.

• pad — количество байтов для выравнивания. Обычно это константа NLMSG_ALIGNTO, равная 4 байтам.

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

int nlmsg_append(nl_msg *msg, void *data, size_t len, int pad);

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

// Размер данных без выравнивания.

int nlmsg_size(int payloadlen);

// Общий размер данных, включая заполнение для выравнивания.

int nlmsg_total_size(int payloadlen);

// Размер выравнивания.

int nlmsg_padlen(int payloadlen);

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

void nlmsg_free(nl_msg *msg);

Разбор пачки сообщений производится функцией:

nlmsghdr *nlmsg_next(nlmsghdr *hdr, int *remaining);

Она автоматически вычитает размер предыдущего сообщения из оставшегося количества байтов.

Чтобы понять, есть ли в потоке еще сообщения, существует функция nlmsg_ok(), которая возвращает true, если длина сообщения не более remaining байт:

int nlmsg_ok(const nlmsghdr *hdr, int remaining);

Функция nlmsg_valid_hdr() проверяет корректность размера заголовка:

int nlmsg_valid_hdr(const nlmsghdr *hdr, int hdrlen);

Если он меньше, чем выровненная длина, переданная в hdr_len, функция вернет 0, иначе 1.

Используются эти функции следующим образом:

void parse_messages(void *stream, int length)

{

nlmsghdr *hdr = stream;

while (nlmsg_ok(hdr, length))

{

// Здесь можно работать с конкретным сообщением.

hdr = nlmsg_next(hdr, &length);

}

}

Это же поведение реализовано в следующем макросе, позволяющем сократить объем типового кода:

nlmsghdr *hdr;

nlmsg_for_each(hdr, stream, length)

{

// Обработка сообщения.

}

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

// Указатель на полезные данные.

void *nlmsg_data(const nlmsghdr *nlh);

// Указатель на конец полезных данных плюс выравнивание.

void *nlmsg_tail(const nlmsghdr *nlh);

// Получить размер данных из заголовка.

int nlmsg_datalen(const nlmsghdr *nlh);

Данные могут содержать атрибуты, значение и размер которых можно получить через следующие функции:

nlattr *nlmsg_attrdata(const nlmsghdr *hdr, int hdrlen);

int nlmsg_attrlen(const nlmsghdr *hdr, int hdrlen);

Внутри эта функция вызывает функцию nla_parse(), которая строит массив атрибутов:

struct nlattr

{

__u16 nla_len;

__u16 nla_type;

};

struct nla_policy

{

// Тип атрибута или NLA_UNSPEC.

uint16_t type;

// Минимальный требуемый размер полезной нагрузки.

uint16_t minlen;

// Максимальный требуемый размер полезной нагрузки.

uint16_t maxlen;

};

int nla_parse(nlattr *tb[], int maxtype, nlattr *head, int len,

nla_policy *policy);

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

• tb — массив для заполнения размером maxtype + 1.

• maxtype — максимальный тип атрибута.

• head — заголовок потока атрибутов.

• len — длина потока атрибутов.

• policy — политика проверки атрибутов.

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

Если размер заголовка больше нуля, функция nlmsg_parse() сначала вызовет функцию nlmsg_valid_hdr(), чтобы проверить, помещается ли заголовок протокола в сообщение, а затем — функцию nla_parse():

int nlmsg_parse(nlmsghdr *hdr, int hdrlen, nlattr **attrs, int maxtype,

nla_policy *policy);

Функции nlmsg_attrdata() и nlmsg_attrlen() также используются в функции проверки атрибутов заголовка:

int nlmsg_validate(nlmsghdr *hdr, int hdrlen, int maxtype,

nla_policy *policy);

Работа с атрибутами — одна из важных функциональных возможностей libnl-core.

Атрибуты возможно не только получать, но и добавлять, для чего используются следующие функции:

// Зарезервировать место под атрибуты.

nlattr *nla_reserve(nl_msg *msg, int attrtype, int len);

// Добавить атрибут.

int nla_put(nl_msg *msg, int attrtype, int attrlen, const void *data);

Для атрибута произвольной структуры в attrtype необходимо передать ATTR_MY_STRUCT. Но есть также функции, добавляющие атрибуты конкретных типов, например nla_put_u32() или nla_put_string().

Чтобы получить атрибуты, используются функции nla_get_string(), nla_gett_u32() и др.

Прочие возможности libnl-core

Основная библиотека содержит еще несколько важных и полезных возможностей:

• API для работы с группами многоадресной рассылки. Каждый сокет может подписаться на любое количество групп многоадресной рассылки Netlink-протокола, чтобы получать сообщения, отправленные в группы. Обычно многоадресная рассылка используется для уведомления приложений о событиях.

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

• Подсистему кэширования. Менеджер кэша и API для работы с ним. Управляет кэшами значений и поддерживает их актуальность при изменении состояния ядра.

• Работу с абстрактными типами данных. Поддерживает абстрактные типы данных высокого уровня, которые используются большинством сетевых протоколов. Это, например, структуры адресов. Библиотека содержит API для выделения под них памяти, работы с ними как с атрибутами, их сравнения и т.п.

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

libnl-route

Библиотека содержит API, специфичный для управления следующими элементами:

• Адресами. Библиотека позволяет их добавлять, удалять, привязывать к интерфейсам.

• Конфигурацией интерфейсов. Позволяет установить MTU, флаги состояния и другие параметры.

• Маршрутизацией, правилами маршрутизации и кэшем адресов соседей.

• Дисциплинами очередей.

• Шинами, например с CAN.

• Таблицей перенаправления пакетов, или FIB

и другие.

API этой библиотеки начинается с префикса rtnl_. Функции для работы с FIB начинаются с flnl_. Библиотека активно использует кэш, предоставляемый основной библиотекой.

Полностью изучать состав API мы не будем, рассмотрим пример получения интерфейсов:

#include <netlink/route/link.h>

...

nl_cache *cache = nullptr;

// Выделить кэш соединений. Запрос будет сделан автоматически.

if (rtnl_link_alloc_cache(sock, AF_UNSPEC, &cache)) < 0)

{

// Ошибка.

}

// Получить интерфейс по имени.

rtnl_link *link = rtnl_link_get_by_name(cache, "eno2");

// Можно получить интерфейс из кэша по индексу.

// link *rtnl_link_get(cache, if_index);

if (!link)

{

// Интерфейс не существует.

}

// Обработать соединение.

// Уменьшить счетчик ссылок на объект в кэше, удалить неиспользуемый объект.

rtnl_link_put(link);

// Освободить кэш.

nl_cache_put(cache);

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

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

libnl-idiag

Это библиотека для работы с сокетами SOCK_DIAG. Она содержит отдельную функцию для подключения сокета:

int idiagnl_connect(nl_sock *sk);

И функцию для отправки сообщений:

int idiagnl_send_simple(nl_sock *sk, int flags, uint8_t family,

uint16_t states, uint16_t ext);

Также основной API содержит несколько функций для преобразования атрибутов и состояний в строковую форму.

Библиотека подразделяется на следующие части:

• Получение информации о памяти SOCK_DIAG. Дает возможность получать размеры очередей приема, готовых к отправке данных TCP, переданных данных и очереди для будущего использования TCP.

• Сообщения SOCK_DIAG. API для получения и создания сообщений SOCK_DIAG, например idiagnl_msg_get(), и группа функций idiagnl_msg_set_*(), а также функции для установки и получения атрибутов.

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

• Информация по алгоритму предотвращения перегрузки TCP Vegas.

libnl-genl

Библиотека Generic Netlink. Ее функции:

• Преобразует имена семейств Generic Netlink в числовые идентификаторы. Для этого используется компонент ядра — контроллер, а библиотека предоставляет API для работы с ним.

• Позволяет регистрировать семейства и команды Netlink.

Для работы с Generic сокеты Netlink используют функции genl_connect(), genl_send_simple() и подобные. В книге мы не рассматриваем подсистему Generic Netlink, не будем подробно описывать ее API.

libnl-nf

Функции этой библиотеки предназначены для управления сетевым фильтром Netfilter. Функция для подключения к сокету, управляющему Netfilter:

int nfnl_connect(nl_sock *sk);

Функция для отправки данных:

int nfnl_send_simple(nl_sock *sk, uint8_t subsys_id, uint8_t type,

int flags, uint8_t family, uint16_t res_id);

Эта функция просто заполняет буфер идентификатором ресурса и вызывает nl_send_simple().

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

Библиотека содержит широкий спектр API для работы с отслеживанием соединений, а также со следующими элементами:

• Сообщениями. Для обработки сообщений используются такие функции, как nlmsg_alloc(), выделяющая буфер и nfnlmsg_put(), добавляющая данные в сообщение.

• Кэшем ожиданий.

• Очередями.

• Журналом Netfilter. Функции выполняют разбор и добавление записей.

Пример работы с libnl

Пример ниже активирует или деактивирует указанный ему интерфейс.

Для работы с библиотекой включим необходимые заголовочные файлы:

extern "C"

{

#include <net/if.h>

#include <netlink/netlink.h>

#include <netlink/route/link.h>

}

...

Сначала нужно создать новый сокет Libnl и подключить его к требуемой подсистеме:

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

{

...

const std::string if_name{argv[1]};

const std::string if_action{argv[2]};

// Создать экземпляр структуры для Libnl сокета.

std::unique_ptr<nl_sock, decltype(&nl_socket_free)>

sock{nl_socket_alloc(), &nl_socket_free};

// Кэш Netlink.

nl_cache *cache = nullptr;

int error_code = 1;

if (!sock)

{

std::cerr << "Can't allocate socket!" << std::endl;

return EXIT_FAILURE;

}

try

{

// Подключить сокет к подсистеме RTNL.

if ((error_code = nl_connect(sock.get(), NETLINK_ROUTE)) < 0)

{

// Код ошибки пригодится далее, при обработке.

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

"Unable to connect socket");

}

std::cout << "Socket connected." << std::endl;

Затем требуется выделить кэш. Изменение состояния интерфейса производится через установку или сброс флага IFF_UP.

Состояние меняется в отдельной копии параметров, которые применяются за один вызов rtnl_link_change():

// Выделить кэш.

if ((error_code = rtnl_link_alloc_cache(sock.get(), AF_UNSPEC,

&cache)) < 0)

... ;

// Получить сетевой интерфейс по имени.

std::unique_ptr<rtnl_link, decltype(&rtnl_link_put)>

link{rtnl_link_get_by_name(cache, if_name.c_str()),

&rtnl_link_put};

if (!link) ... ;

// Получить и вывести флаг состояния интерфейса.

std::cout

<< "\"" << if_name << "\" interface acquired\n"

<< "Current \"" << if_name << "\" status: "

<< ((rtnl_link_get_flags(link.get()) & IFF_UP) ? "up" : "down")

<< std::endl;

// Новая копия параметров интерфейса будет использована для

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

const std::unique_ptr<rtnl_link, decltype(&rtnl_link_put)>

change{rtnl_link_alloc(), &rtnl_link_put};

// Установить или сбросить флаг.

if ("a" == if_action) rtnl_link_set_flags(change.het(), IFF_UP);

else if ("d" == if_action) rtnl_link_unset_flags(change.get(),

IFF_UP);

else std::cerr << "Unknown action type!" << std::endl;

// Применить изменения.

if ((error_code = rtnl_link_change(sock.get(),

link.get(),

change.get(), 0)) < 0) ... ;

}

Для вывода ошибки на экран библиотека содержит отдельную функцию, похожую на perror(), но принимающую код ошибки явно:

catch (const std::system_error &e)

{

// Вывести ошибку libnl.

nl_perror(e.code().value(), e.what());

}

// Освободить выделенный кэш.

if (cache) nl_cache_free(cache);

return error_code;

}

Приведенное описание помогает представить объем и сложность пакета. Этот пакет охватывает практически всю подсистему Netlink.

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

Чтобы сохранить интерфейс, но взять из кода лишь необходимое, не разбираясь в полной кодовой базе пакета, в проекте OpenWRT реализовали свою библиотеку — libnl-tiny58.

Она содержит подмножество libnl библиотеки, но состоит всего из полутора десятков C-файлов и собирается с использованием CMake.

Библиотека Libmnl

Другой вариант для работы с Netlink — библиотека libmnl59, которая предоставляет минималистичный интерфейс. Она берет на себя задачи проверки и построения заголовка Netlink и пакета запроса, а также синтаксический анализ ответов.

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

Работа с сокетом Netlink начинается с его открытия:

#include <libmnl.h>

// Открыть и вернуть сокет Netlink.

mnl_socket *mnl_socket_open(int bus);

// Открыть "сокет", передав флаги сокета.

mnl_socket *mnl_socket_open2(int bus, int flags);

// Создать экземпляр mnl_socket на основе существующего дескриптора.

mnl_socket *mnl_socket_fdopen(int fd);

Идентификатор шины bus — это тип сокета Netlink, например NETLINK_GENERIC, NETLINK_ROUTE, NETLINK_SOCK_DIAG и прочие.

Для получения дескриптора сокета и его порта можно использовать следующие функции:

int mnl_socket_get_fd(const mnl_socket *nl);

unsigned int mnl_socket_get_portid(const mnl_socket *nl);

Также есть несколько функций для проверки корректности полей. Например, следующая функция проверяет, задан ли у сообщения PID, и если передан ненулевой portid, он равен указанному в заголовке:

bool mnl_nlmsg_portid_ok(const nlmsghdr *nlh, unsigned int portid);

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

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

bool mnl_nlmsg_seq_ok(const nlmsghdr *nlh, unsigned int seq);

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

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

int mnl_socket_bind(mnl_socket *nl, unsigned int groups, pid_t pid);

В конце работы сокет необходимо закрыть, для чего есть отдельная функция:

int mnl_socket_close(mnl_socket *nl);

Отправка и получение данных выполняются двумя функциями:

ssize_t mnl_socket_sendto(const mnl_socket *nl, const void *req, size_t siz);

ssize_t mnl_socket_recvfrom(const mnl_socket *nl, void *buf, size_t siz);

Также есть функции для установки и получения опций:

int mnl_socket_setsockopt(const mnl_socket *nl, int type, void *buf,

socklen_t len);

int mnl_socket_getsockopt(const mnl_socket *nl, int type, void *buf,

socklen_t *len);

Опции были перечислены в главе 8.

Функции для построения сообщения в буфере:

nlmsghdr *mnl_nlmsg_put_header(void *buf);

void *mnl_nlmsg_put_extra_header(nlmsghdr *nlh, size_t size);

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

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

// Указатель на начало данных.

void *mnl_nlmsg_get_payload(const nlmsghdr *nlh);

// Указатель на смещение от начала данных.

void *mnl_nlmsg_get_payload_offset(const nlmsghdr *nlh, size_t offset);

// Указатель на конец данных.

void *mnl_nlmsg_get_payload_tail(const nlmsghdr *nlh);

// Размер данных.

size_t mnl_nlmsg_get_payload_len(const nlmsghdr *nlh);

// Получить выровненный размер.

size_t mnl_nlmsg_size(size_t len);

Так же как в libnl, есть возможность итерироваться по сообщениям, состоящим из нескольких частей:

bool mnl_nlmsg_ok(const nlmsghdr *nlh, int len);

nlmsghdr *mnl_nlmsg_next(const nlmsghdr *nlh, int *len);

Функция mnl_nlmsg_next() переходит на следующее за текущим сообщение, а mnl_nlmsg_ok() проверяет, что в буфере еще достаточно места для хранения сообщения.

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

Следующая функция выводит человеко-читаемое представление сообщения в переданный файловый поток:

void mnl_nlmsg_fprintf(FILE *fd, const void *data, size_t datalen,

size_t extra_header_size);

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

Для работы с атрибутами предоставляется группа функций:

• mnl_attr_get_u8() — получает значения;

• mnl_attr_put_u8_check() — получает значения с проверкой границ;

• mnl_attr_put_u8() — устанавливает значения атрибутов.

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

// Получение типа атрибута.

uint16_t mnl_attr_get_type(const nlattr *attr);

// Получение длины атрибута.

uint16_t mnl_attr_get_len(const nlattr *attr);

// Получение длины полезных данных атрибута.

uint16_t mnl_attr_get_payload_len(const nlattr *attr);

// Получение данных атрибута.

void *mnl_attr_get_payload(const nlattr *attr);

// Установка данных атрибута и установка с проверкой.

void mnl_attr_put(nlmsghdr *nlh, uint16_t type, size_t len, const void *data);

bool mnl_attr_put_check(nlmsghdr *nlh, size_t buflen, uint16_t type,

size_t len, const void *data);

Существует отдельный набор функций для разбора последовательности атрибутов:

// Тип обработчика.

typedef int (*mnl_attr_cb_t)(const nlattr *attr, void *data);

// Разобрать атрибуты в сообщении. Разбор начинается с offset.

int mnl_attr_parse(const nlmsghdr *nlh, unsigned int offset, mnl_attr_cb_t cb,

void *data);

// Разобрать атрибуты в данных Netlink-сообщения.

int mnl_attr_parse_payload(const void *payload, size_t payload_len,

mnl_attr_cb_t cb, void *data);

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

Обработчику передается заголовок атрибута, включающий длину и тип, а также data — указатель на произвольные данные, переданные при вызове.

Если он завершается, возвращая MNL_CB_ERROR или MNL_CB_STOP, итерация по атрибутам завершается. Если MNL_CB_OK — итерация будет продолжаться, пока остаются атрибуты.

Атрибуты могут содержать вложенные атрибуты, для которых предоставляются функции:

// Добавить заголовок, идентифицирующий вложенный атрибут.

nlattr *mnl_attr_nest_start(nlmsghdr *nlh, uint16_t type);

// Делает то же самое, но проверяет, что размер буфера достаточен.

nlattr *mnl_attr_nest_start_check(nlmsghdr *nlh, size_t buflen,

uint16_t type);

// Завершить вложенный атрибут, установив поле его длины.

void mnl_attr_nest_end(nlmsghdr *nlh, nlattr *start);

// Отменить вложение атрибута, скорректировать длину сообщения.

void mnl_attr_nest_cancel(nlmsghdr *nlh, nlattr *start);

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

int mnl_attr_parse_nested(const nlattr *attr, mnl_attr_cb_t cb, void *data);

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

Внимание! В буфере может оставаться свободное место, которое меньше длины сообщения.

Рис. 12.2. Батч из сообщений

Разбирать такие батчи можно, используя следующие функции:

mnl_nlmsg_batch *mnl_nlmsg_batch_start(void *buf, size_t bufsiz);

bool mnl_nlmsg_batch_next(mnl_nlmsg_batch *b);

void mnl_nlmsg_batch_stop(mnl_nlmsg_batch *b);

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

// Тип обработчика. Параметр data задается вызываемой callback-функцией.

typedef int (*mnl_cb_t)(const nlmsghdr *nlh, void *data);

int mnl_cb_run(const void *buf, size_t numbytes, unsigned int seq,

unsigned int portid, mnl_cb_t cb_data, void *data);

int mnl_cb_run2(const void *buf, size_t numbytes, unsigned int seq,

unsigned int portid, mnl_cb_t cb_data, void *data,

const mnl_cb_t *cb_ctl_array, unsigned int cb_ctl_array_len);

Параметры указанных функций:

• buf — буфер, содержащий сообщения Netlink.

• numbytes — количество байтов в буфере.

• seq — ожидаемый номер последовательности.

• portid — ожидаемый идентификатор порта.

• cb_data — указатель на обработчик.

• data — передаваемые в обработчик данные.

• cb_ctl_array — массив обработчиков управляющих сообщений.

• cb_ctl_array_len — длина массива cb_ctl_array.

Функции возвращают значения, которые вернет функция-обработчик.

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

Рассмотрим пример, в котором получим все адреса указанного семейства: IPv4 или IPv6, на выбор.

Сначала включим необходимые заголовки:

extern "C"

{

#include <arpa/inet.h>

#include <libmnl/libmnl.h>

#include <linux/if.h>

#include <linux/if_link.h>

#include <linux/rtnetlink.h>

}

...

Теперь реализуем обработчик атрибутов:

static int data_attr_cb(const nlattr *attr, void *data)

{

assert(attr);

// В data передается адрес таблицы атрибутов.

const nlattr **tb = static_cast<const nlattr **>(data);

const int type = mnl_attr_get_type(attr);

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

if (mnl_attr_type_valid(attr, IFA_MAX) < 0) return MNL_CB_OK;

// Типов атрибутов может быть много, проверку удобно делать в switch.

switch (type)

{

// Атрибут — адреса.

case IFA_ADDRESS:

// Проверить на корректность.

if (mnl_attr_validate(attr, MNL_TYPE_BINARY) < 0)

{

perror("mnl_attr_validate");

// Завершить обработку с ошибкой.

return MNL_CB_ERROR;

}

break;

}

// Добавить атрибут в таблицу.

tb[type] = attr;

// Продолжить обработку.

return MNL_CB_OK;

}

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

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

static int data_cb(const nlmsghdr *nlh, void *data)

{

assert(attr);

assert(data);

// Адрес таблицы будет передан через параметр data обработчика атрибутов.

nlattr *tb[IFA_MAX + 1] = {};

// Получить данные: информацию об интерфейсе.

ifaddrmsg *ifa = static_cast<ifaddrmsg *>(

mnl_nlmsg_get_payload(static_cast<const nlmsghdr *>(nlh)));

std::cout

<< "index = " << ifa->ifa_index << ", family = " << +ifa->ifa_family;

// Разобрать атрибуты: передать таблицу в функцию разбора атрибутов.

mnl_attr_parse(nlh, sizeof(*ifa), data_attr_cb, tb);

std::cout << " addr = ";

if (tb[IFA_ADDRESS])

{

// У интерфейса есть первичный адрес, получим его.

void *addr = mnl_attr_get_payload(tb[IFA_ADDRESS]);

char out[INET6_ADDRSTRLEN];

// Преобразуем в печатную форму.

if (inet_ntop(ifa->ifa_family, addr, out, sizeof(out)))

std::cout << out << " " << std::endl;

}

Выведем тип интерфейса:

std::cout << " scope = ";

// Вывести тип интерфейса.

switch (ifa->ifa_scope)

{

...

case 253:

std::cout << "link ";

break;

case 254:

std::cout << "host ";

break;

...

default:

std::cout << ifa->ifa_scope << " ";

break;

}

std::cout << std::endl;

return MNL_CB_OK;

}

После того как атрибуты разобраны, обработчик выводит их на экран, запускаясь в функции main() после приема ответов на запрос к ядру.

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

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

{

...

std::vector<char> buf(MNL_SOCKET_BUFFER_SIZE);

unsigned int seq = 0;

// Создать заголовок сообщения.

nlmsghdr *const nlh = mnl_nlmsg_put_header(buf.data());

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

nlh->nlmsg_type = RTM_GETADDR;

nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;

nlh->nlmsg_seq = seq = time(nullptr);

// Добавить дополнительный заголовок, в котором будет указано

// семейство адресов.

rtgenmsg *const rt = static_cast<rtgenmsg *>(mnl_nlmsg_put_extra_header(

static_cast<nlmsghdr *>(nlh), sizeof(rtgenmsg)));

assert(argv[1]);

const std::string s_type = argv[1];

// Выбрать семейство адресов.

if ("inet" == s_type) rt->rtgen_family = AF_INET;

else if ("inet6" == s_type) rt->rtgen_family = AF_INET6;

Теперь следует создать новый сокет и отправить сообщение:

// Открыть сокет Netlink.

mnl_socket *nl = mnl_socket_open(NETLINK_ROUTE);

if (!nl)

{

perror("mnl_socket_open");

return EXIT_FAILURE;

}

try

{

// Привязать к сокету PID.

if (mnl_socket_bind(nl, 0, MNL_SOCKET_AUTOPID) < 0)

{

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

"mnl_socket_bind");

}

// Получить идентификатор порта.

const unsigned int portid = mnl_socket_get_portid(nl);

// Отправить сообщение.

if (mnl_socket_sendto(nl, nlh, nlh->nlmsg_len) < 0) ... ;

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

Вычитаем ответы, пока они будут приходить:

// Получить ответ.

int ret = mnl_socket_recvfrom(nl, buf.data(), buf.size());

while (ret > 0)

{

// Обработать полученные сообщения, фильтруя по portid.

ret = mnl_cb_run(buf.data(), ret, seq, portid, data_cb, nullptr);

if (ret <= MNL_CB_STOP) break;

// Продолжать читать ответы.

ret = mnl_socket_recvfrom(nl, buf.data(), buf.size());

}

if (-1 == ret) ... ;

}

catch (const std::system_error &e)

{

... ;

}

// Закрыть сокет и освободить память.

mnl_socket_close(nl);

return EXIT_SUCCESS;

}

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

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

➭ build/bin/b01-ch11-lib-mnl-example inet

index = 1, family = 2 addr = 127.0.0.1  scope = host

index = 2, family = 2 addr = 10.0.0.1  scope = global

index = 3, family = 2 addr = 192.168.2.13  scope = global

index = 5, family = 2 addr = 172.18.0.1  scope = global

Видно, что были получены все IPv4-адреса.

С данной библиотекой работать несколько сложнее, чем с предыдущей, но для этого не требуется изучать большой объем API.

Библиотека Libnetlink

Внимание! Эта библиотека является внутренней для пакета iproute2. Ее нежелательно использовать в проектах. В некоторых системах, например в Manjaro Linux, она установлена, в других, например в Debian, не поставляется. В образе, который используется для сборки примеров книги, пакет iproute2 изменен так, чтобы устанавливать библиотеку.

Библиотека libnetlink предоставляет интерфейс высокого уровня для получения доступа к RTNetlink. Данную библиотеку использует, например, команда ip route, обращаясь к подсистеме Netlink.

Функции библиотеки работают с дескриптором сокета RTNetlink. Дескриптор описывается следующей структурой:

struct rtnl_handle

{

// Дескриптор.

int fd;

// Адреса эндпоинтов Netlink.

struct sockaddr_nl local;

struct sockaddr_nl peer;

// Номер последовательности.

uint32_t seq;

uint32_t dump;

};

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

Для начала работы с библиотекой требуется открыть сокет rtnetlink и сохранить его состояние в дескриптор, используя функцию rtnl_open():

#include <asm/types.h>

#include <libnetlink.h>

#include <linux/netlink.h>

#include <linux/rtnetlink.h>

int rtnl_open(rtnl_handle *rth, unsigned subscriptions);

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

• rth — дескриптор, в котором хранится состояние.

• subscriptions — битовая карта групп многоадресной рассылки rtnetlink, членом которых будет сокет.

Запросы выполняют следующие функции:

// Запросить полный дамп базы для семейства адресов.

int rtnl_wilddump_request(rtnl_handle *rth, int family, int type);

// Запросить полный дамп.

int rtnl_dump_request(rtnl_handle *rth, int type, void *req, int len);

Параметры функций:

• family — семейство адресов Netlink.

• type — тип сообщения Netlink.

• req — запрос.

• len — длина буфера req.

Они возвращают число отправленных байтов в случае успеха и отрицательное значение в случае неудачи.

Функция rtnl_dump_filter() получает данные Netlink после запроса и отфильтровывает их:

int rtnl_dump_filter(rtnl_handle *rth,

int (*filter)(sockaddr_nl *, nlmsghdr *n, void *),

void *arg1,

int (*junk)(sockaddr_nl *, nlmsghdr *n, void *),

void *arg2);

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

• rth — дескриптор.

• filter — обработчик-фильтр.

• arg1 — третий параметр фильтра, который передается в функцию пользователем.

• junk — обработчик, вызываемый при несоответствии ответа запросу.

• arg2 — аргумент, передаваемый обработчику junk.

Обратный вызов фильтра получает адрес источника сообщения, само сообщение и arg1 в качестве аргументов и проверяет, требуется ли полученное сообщение. Если обработчик возвращает 0, это означает, что фильтр пройден.

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

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

int rtnl_talk(rtnl_handle *rtnl, nlmsghdr *n, pid_t peer,

unsigned groups, nlmsghdr *answer,

int (*junk)(sockaddr_nl *, nlmsghdr *n, void *),

void *jarg);

Параметры рассматриваемой функции:

• rtnl — дескриптор.

• n — заголовок сообщения.

• peer — идентификатор процесса-отправителя, по нему может фильтроваться ответ на запрос. Поле nl_pid в Netlink-заголовке.

• groups — поле nl_groups в Netlink-заголовке.

• answer — если параметр задан и вызов завершился без ошибки, сюда будет записан ответ.

• junk — обработчик, вызываемый при несоответствии ответа запросу.

• jarg — параметр обработчика, задаваемый пользователем.

Обработчики junk вызываются, если PID ответа или номера последовательности не соответствуют идентификаторам в заголовке и peer. Они реализованы и могут быть использованы не везде.

Функция вернет 0 в случае успеха и –1 при ошибке.

Для отправки запроса существует несколько функций, и прежде всего — функция rtnl_send():

int rtnl_send(rtnl_handle *rth, char *buf, int len);

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

• rth — дескриптор, возвращенный функцией rtnl_open().

• buf — буфер для отправки.

• len — длина буфера.

Функция отправляет буфер длиной len и возвращает количество отправленных данных или –1 в случае ошибки.

В новых версиях библиотеки есть похожая функция:

int rtnl_send_check(rtnl_handle *rth, const void *buf, int len);

Кроме отправки сообщения, она читает ответ и разбирает сообщения. Если отправка сообщения или прием вернули –1 либо появляется сообщение, имеющее тип NLMSG_ERROR, функция вернет –1, в противном случае 0.

Функции, добавляющие атрибуты в сообщение:

// Добавить атрибут data типа type и со значением data в сообщение Netlink,

// которое является частью буфера длины maxlen.

int addattr32(nlmsghdr *n, int maxlen, int type, __u32 data);

// Добавить атрибут переменной длины типа type и со значением data

// и длиной alen в сообщение Netlink n, которое является частью буфера

// длины maxlen. Данные копируются.

int addattr_l(nlmsghdr *n, int maxlen, int type, void *data, int alen);

// Инициализировать атрибут rta типа type со значением data.

int rta_addattr32(rtattr *rta, int maxlen, int type, __u32 data);

// Инициализировать атрибут rta типа type со значением data длиной alen.

int rta_addattr_l(rtattr *rta, int maxlen, int type, void *data, int alen);

Функция для разбора атрибутов:

void parse_rtattr(rtattr *tb[], int max, rtattr *rta, int len);

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

• tb — массив, в который будут помещены атрибуты.

• max — максимальное значение типа атрибута.

• rta — указатель на атрибуты.

• len — длина атрибутов.

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

В целом библиотека по составу и функционированию API похожа на предыдущие, особенно на lib-mnl, и сейчас пакет iproute2 в некоторых местах уже использует lib-mnl.

Пакет Pyroute2

Пакет Pyroute260 предназначен для работы с Netlink из Python. Pyroute2 изначально работает с Netlink API в Linux и сокетами домена PF_ROUTE в BSD-системах. Подмножество API этого пакета, такое как управление маршрутизацией, работает на ОС Windows.

Ядро библиотеки переходит с многопоточной архитектуры на использование asyncio.

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

Пакет можно использовать для работы с существующими протоколами, такими как RTNL, а также для любых других Netlink-протоколов: он позволяет создавать парсеры для PDU, имеющих структуру (type, length, payload).

В пакете реализована ограниченная поддержка протоколов PF_ROUTE и 9P2000. Первый используется для мониторинга сетевых настроек в BSD-системах, а второй — реализует сетевую файловую систему и RPC в ОС Plan9.

Пакет реализован на чистом Python и не зависит от сторонних библиотек.

Основные составляющие пакета:

• кодировщик и декодировщик сообщений Netlink;

• модули, специфичные для конкретного Netlink-протокола.

На низком уровне он представляет собой расширенный API поверх объектов сокетов, предоставляющий функциональность, схожую с функциональностью библиотеки libnl-core:

• помогает открывать сокеты Netlink и привязывать к ним адреса;

• поддерживает общие протоколы Netlink и группы многоадресной рассылки;

• позволяет создавать, кодировать и декодировать сообщения Netlink и PF_ROUTE.

RTNL API представлен в основном классом IPRoute для Netlink в текущем сетевом пространстве имен, а также классом NetNS для работы в другом пространстве. Архитектура этих классов для платформы Linux показана на рис. 12.3.

Рис. 12.3. Архитектура PyRoute2 для Linux

Класс IPRoute для разных платформ реализован по-разному.

В Linux, например, он наследует классу IPRSocket, реализующему специфические особенности протокола RTNL и некоторые дополнительные операции вне протокола, выполнимые только через ioctl-вызовы. Также он наследует mix-in классу RTNL_API, реализующему основные низкоуровневые методы, для любого RTNL-совместимого сокета в Linux.

Например, это фильтрация сообщений, poll(), реальный метод дампа Netlink, метод link() для работы с сетевыми интерфейсами, методы для работы с маршрутами, контролем трафика, записями соседей и т.п.

В других системах, например в BSD, методы реализованы прямо в классе IPRoute, то есть класс IPRoute в данном случае является интерфейсным. Рассмотрим несколько его методов.

Метод dump() возвращает параметры сетевых интерфейсов, адресов и маршрутов, полученные с использованием Netlink:

def dump(self, groups=None)

Основные методы для работы с сетевыми объектами предоставляют единообразный API:

# Операции с ARP-таблицей.

def neigh(self, command, **spec)

# Операции с сетевыми интерфейсами.

def link(self, command, **spec)

# Операции с IP-адресами.

def addr(self, command, **spec)

# Операции с маршрутами

def route(self, command, **spec)

Каждый метод из рассмотренных принимает в качестве первого и единственного позиционного аргумента команду dump, get, add, del, set и т.п., а также специфические именованные аргументы, например index, link, state. Все они описаны в документации к методам.

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

По сходному принципу реализованы методы:

• brport() — работа с портами мостов.

• vlan_filter() — управление фильтрами VLAN для мостов.

• fdb() — работа с базой пересылки мостов.

• tc() — управление классами и процедурами контроля трафика.

Такой подход позволяет строить на основе IPRoute более высокоуровневые API, где методы neigh(), link(), addr() и route() привязаны к типу сетевого объекта, а конкретная операция выбирается среди dump, get, add, del, set уже независимым от метода уровнем логики.

Таковы get_links(), get_neighbours() и подобные, чаще всего реализованные как более высокоуровневые обертки вокруг соответствующего низкоуровневого метода типа link(), neigh() и т.д.

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

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

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

import sys

from pyroute2 import IPRoute

def print_if_attrs(if_attrs):

# Атрибуты — список кортежей "имя"/"значение".

for attr_name, attr_val in if_attrs:

if 'IFLA_OPERSTATE' == attr_name:

print(f'Oper state: {attr_val}')

elif 'IFLA_ADDRESS' == attr_name:

print(f'Address: {attr_val}')

# Объект API.

with IPRoute() as ipr:

# Получить индексы сетевых интерфейсов по имени.

devs = ipr.link_lookup(ifname=sys.argv[1])

if not devs:

print(f'Device "{sys.argv[1]}" was not found!')

sys.exit(1)

# Индекс сетевого интерфейса.

dev_index = devs[0]

Сначала мы создали экземпляр класса IPRoute и получили индекс интерфейса по его имени. Метод link_lookup() и подобные реализованы через методы класса IPRoute низкого уровня.

Выполним печать атрибутов и манипуляции с адресами и состояниями интерфейса:

print(f'Devices count = {len(devs)}, Device index = {dev_index}')

print_if_attrs(ipr.link('get', index=dev_index)[0]['attrs'])

link_state = 'down'

try:

# Получить состояние интерфейса для восстановления.

link_state = ipr.link('get', index=dev_index)[0]['state']

# Отключить интерфейс.

ipr.link('set', index=dev_index, state='down')

# Изменить MAC интерфейса, затем переименовать его.

ipr.link('set', index=dev_index, address='00:11:22:33:44:55',

ifname='eno2')

# Добавить первичный IP-адрес.

ipr.addr('add', index=dev_index, address='10.0.0.1', mask=24,

broadcast='10.0.0.255')

finally:

# Восстановить состояние интерфейса.

ipr.link('set', index=dev_index, state=link_state)

print(f'Interface {sys.argv[1]} state restored...')

print_if_attrs(ipr.link('get', index=dev_index)[0]['attrs'])

Запустим пример и увидим изменения, которые вносит код:

➭ sudo ./run src/book01/ch12/python/pyroute2-low-level-example.py eno2

Devices count = 1, Device index = 2

Oper state: DOWN

Address: 00:11:22:33:44:55

Interface eno2 state restored...

Oper state: DOWN

Address: 00:11:22:33:44:55

Низкоуровневый API достаточно прост, отображается на сокеты Netlink практически напрямую и не имеет особых функций. Интерес представляют более высокоуровневые интерфейсы, например интерфейс NDB.

Кроме методов, объекты NDB представляют собой словари, которые содержат атрибуты сети, интерфейсов, маршрутов, адресов и т.д. Большинство атрибутов одноуровневые, но некоторые, например маршруты, предоставляют вложенные структуры, то есть являются, по сути, базой данных сетевых параметров, которая выполняет следующие функции:

• проверяет целостность данных;

• реализует транзакции с фиксацией и откатом изменений;

• синхронизирует состояния базы и системы;

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

По сути, NDB — это транзакционная база данных, содержащая записи, которые представляют объекты сетевого стека. Любое изменение в базе данных не отражается сразу в ОС, а ждет, когда будет вызван метод commit().

Неудачная операция во время вызова commit() откатывает все сделанные изменения. Причем откатываются не только явно примененные на уровне ОС изменения, но и те, которые NDB считает зависимыми. При удалении виртуального интерфейса, например VLAN, или адреса с интерфейса ОС удалит также все зависимые маршруты через этот интерфейс. При откате такой транзакции NDB автоматически восстановит все удаленные маршруты.

Общая архитектура NDB показана на рис. 12.4.

Описания объектов сетевого стека и их отношений реализованы в NDB в виде SQL-схемы, а в качестве хранилища NDB использует реляционную БД.

Рис. 12.4. Схема NDB

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

В качестве реляционной СУБД по умолчанию используется SQLite3, но может быть использована PostgreSQL.

Транзакции в NDB могут быть запущены на следующих уровнях:

• уровне объекта — интерфейса, адреса или маршрута. Метод commit() будет вызван у конкретного объекта, и он применит все внесенные в него изменения;

• уровне экземпляра класса NDB. Вызов метода begin() экземпляра NDB создает транзакцию, в которую добавляются новые действия с помощью метода push() экземпляра транзакции.

Протокол работы транзакций, созданных на уровне NDB, таков:

1. У всех объектов, добавленных через push(), вызывается метод commit().

2. Он завершается успешно либо порождает исключение.

3. В последнем случае откатываются все изменения, примененные до момента исключения, включая зависимые.

Этот алгоритм изображен на рис. 12.4.

Во время интерактивной сессии попробуем деактивировать интерфейс, через который доступен интересующий нас адрес, и посмотрим, как NDB откатит изменения, когда адрес станет недоступен.

Рис. 12.5. Создание транзакции NDB

Интерфейс test мы создали предварительно.

Внимание! Для изменения параметров интерфейса необходимо запустить интерпретатор Python с правами суперпользователя.

Для начала создадим экземпляр NDB и проверим, что нужный нам интерфейс и адрес на месте:

>>> from pyroute2 import NDB

>>> ndb = NDB()

>>> ndb.interfaces.summary()

('localhost', 0, 1, 'lo', '00:00:00:00:00:00', 65609, None)

('localhost', 0, 61, 'eth0', 'b0:4f:13:21:75:84', 4099, None)

('localhost', 0, 67, 'test', '56:45:60:94:3a:f2', 65731, 'dummy')

>>> ndb.addresses.summary().filter(ifname='test')

('localhost', 0, 'test', '192.168.14.24', 24)

Как видно из вызова ndb.routes.summary(), интерфейс с именем test имеет адрес 192.168.14.1.

Создадим через него маршрут:

>>> ndb.routes.create(dst='192.168.15.0/24', gateway='192.168.14.1').commit()

...

>>> ndb.routes.summary().filter(ifname='test')

('localhost', 0, 255, 'test', '192.168.14.24', 32, None)

('localhost', 0, 254, 'test', '192.168.14.0', 24, None)

('localhost', 0, 255, 'test', '192.168.14.255', 32, None)

('localhost', 0, 254, 'test', '192.168.15.0', 24, '192.168.14.1')

Подготовим составную транзакцию и включим туда удаление виртуального интерфейса и проверку доступности интересующего нас шлюза 192.168.14.1:

>>> from pyroute2.ndb.transaction import PingAddress

>>> transaction = ndb.begin()

>>> transaction.push(ndb.interfaces['test'].remove())

>>> transaction.push(PingAddress('192.168.14.1'))

>>> transaction.commit()

Перед тем как мы добавили адрес, в транзакцию была добавлена операция удаления интерфейса. Поэтому вызов метода commit() сделает недоступной сеть шлюза 192.168.14.0/24, и транзакция должна откатиться:

>>> transaction.commit()

Traceback (most recent call last):

...

pyroute2.ndb.transaction.CheckProcessException: CheckProcess failed

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

С помощью команды ip mon из пакета iproute2 посмотрим, что в этот момент происходило на уровне операционной системы.

[LINK]67: test: <BROADCAST,NOARP> mtu 1500 qdisc noqueue state DOWN group default

link/ether 56:45:60:94:3a:f2 brd ff:ff:ff:ff:ff:ff

[ADDR]Deleted 67: test inet 192.168.14.24/24 scope global test

valid_lft forever preferred_lft forever

[ROUTE]Deleted local 192.168.14.24 dev test table local proto kernel scope host src 192.168.14.24

[NETCONF]Deleted inet test

[LINK]Deleted 67: test: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default

link/ether 56:45:60:94:3a:f2 brd ff:ff:ff:ff:ff:ff

Удаление интерфейса привело к удалению зависимых адресов и маршрутов.

Внимание! ОС уведомляет об удалении не всех зависимых объектов, а лишь некоторых. Так, хотя маршрут до сети 192.168.15.0/24 был удален, ОС не сообщит об этом. Однако NDB, используя реляционную БД, самостоятельно вычислит такие зависимости.

Затем транзакция откатилась:

[LINK]67: test: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default

link/ether 56:45:60:94:3a:f2 brd ff:ff:ff:ff:ff:ff

[ADDR]67: test    inet 192.168.14.24/24 scope global test

valid_lft forever preferred_lft forever

[ROUTE]local 192.168.14.24 dev test table local proto kernel scope host src 192.168.14.24

[ROUTE]192.168.14.0/24 dev test proto kernel scope link src 192.168.14.24

[ROUTE]broadcast 192.168.14.255 dev test table local proto kernel scope link src 192.168.14.24

[ROUTE]192.168.15.0/24 via 192.168.14.1 dev test proto static

Восстановление маршрута до сети 192.168.15.0/24 попало в журнал последней строкой.

Проверим, что все интересующие нас записи восстановились и в NDB:

>>> ndb.interfaces.summary().filter(ifname='test')

('localhost', 0, 67, 'test', '56:45:60:94:3a:f2', 65731, 'dummy')

>>> ndb.addresses.summary().filter(ifname='test')

('localhost', 0, 'test', '192.168.14.24', 24)

>>> ndb.routes.summary().filter(ifname='test')

('localhost', 0, 255, 'test', '192.168.14.24', 32, None)

('localhost', 0, 254, 'test', '192.168.14.0', 24, None)

('localhost', 0, 255, 'test', '192.168.14.255', 32, None)

('localhost', 0, 254, 'test', '192.168.15.0', 24, '192.168.14.1')

Объекты NDB, будь то интерфейсы, маршруты и т.п., являются контекстными менеджерами, которые вызовут commit() при выходе из блока with:

import sys

from pyroute2 import NDB

# Создать экземпляр NDB. Для отладки необходимо установить параметр log='debug'.

with NDB() as ndb:

iface_ip_addr = '10.0.0.2/24'

try:

# Для запуска транзакции используется контекстный менеджер.

# Сначала будет создан интерфейс с заданным именем,

# в активированном состоянии.

with ndb.interfaces.create(

ifname=sys.argv[1], kind='dummy', state='up'

) as interface:

# Добавить IP-адреса.

interface.add_ip(iface_ip_addr)

interface.add_ip('10.0.0.3/24')

interface.set('mtu', 8192)

# При выходе будет автоматически вызван commit().

# Если адрес есть среди адресов интерфейса, удалить его.

if iface_ip_addr in ndb.addresses:

with ndb.addresses[iface_ip_addr] as addr:

addr.remove()

# Перечислить все адреса всех интерфейсов.

for record in ndb.addresses.summary():

print(record)

finally:

ndb.interfaces[sys.argv[1]].remove().commit()

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

➭ sudo ./run src/book01/ch12/python/pyroute2-ndb-example.py test

('localhost', 0, 'lo', '127.0.0.1', 8)

('localhost', 0, 'eno2', '10.0.0.1', 24)

('localhost', 0, 'wlo1', '192.168.2.13', 24)

('localhost', 0, 'wlo1', 'fe80::4f3e:b28d:ace9:2e05', 64)

('localhost', 0, 'test', '10.0.0.3', 24)

('localhost', 0, 'test', 'fe80::54de:60ff:fe20:ccd7', 64)

Адреса созданного интерфейса видны среди прочих.

В пакете реализованы модули, которые поддерживают некоторые семейства и протоколы:

• arp — поддержка работы с ARP.

• dhcp — поддержка сообщений DHCP.

• ethtool — API для низкоуровневой настройки сетевого интерфейса.

• netlink — в модуле реализованы некоторые базовые структуры различных Netlink-протоколов. Там же доступна простейшая фильтрация пакетов через цель iptables QUEUE, управление и мониторинг оборудования с поддержкой devlink и получение сообщений uevent, используемых в udev. API отправки сообщений не реализован.

• netns — работа с сетевыми пространствами имен.

• nftables — API сетевого фильтра.

• remote — модуль для удаленной работы с сокетами Netlink.

• ipset и wiset — работа с наборами адресов.

• ipvs — виртуальный IP-сервер, реализованный в ядре Linux.

• iwutil — экспериментальный модуль для поддержки nl80211.

• plan9 — реализация протокола 9P2000, который можно использовать как для создания виртуальных файловых систем, так и для RPC. В пакете он используется как основа межкомпонентного RPC.

Пакет Python-netlink

Python-netlink61 — это маленький низкоуровневый пакет для работы с сокетами Netlink. Если сравнивать его с Pyroute2, они соотносятся примерно так же, как libmnl и libnl.

В отличие от Pyroute2, обеспечивающего синхронную работу с сокетами Netlink, пакет Python-netlink асинхронный. Сейчас он работает поверх Trio, хотя автор предполагает перевести его на AnyIO.

Пакет Trio и асинхронность в общем мы рассмотрим в книге 2.

Пакет обеспечивает поддержку контроллера Netlink, а также ограниченную поддержку nl80211 и RTNetlink. Поддержка других семейств сетевых ссылок может быть добавлена относительно легко.

Пакет содержит очень небольшое количество модулей:

• netlink — в корне пакета содержатся типы сокетов, типы запросов и т.п. Здесь реализованы:

• NetlinkMessage — базовый класс сообщения Netlink. Каждое сообщение имеет три атрибута: type, flags, payload.

• NetlinkSocket — абстракция сокета Netlink.

• connect(family) — функция, которая создает новый сокет Netlink, привязывает адрес, устанавливает опции NLM_F_REQUEST, NLM_F_ACK и возвращает экземпляр класса NetlinkSocket.

• netlink.streams — классы потоков данных:

• Класс StreamOut — выходной поток, например, для атрибутов. Данные, записанные в этот поток, можно затем получить в бинарной форме.

• Класс StreamIn — входной поток, из которого данные могут быть декодированы в объекты с атрибутами.

• netlink.attributes — общие классы и функции для работы с атрибутами:

• Класс AttributeType — общий класс для типов атрибутов, содержащий методы кодирования и декодирования разных типов.

• Класс Policy — политика атрибутов, возвращаемая запросом Netlink: макcимальное значение типа атрибута, ограничение длины и т.п.

• Функции для создания типов атрибутов: u8(), u16(), u32(), u64(), s8() — s64(), binary(), string(), array(), dict(), flag(). Они просто оборачивают типы в класс AttributeType.

• Функции для представления специальных типов nested() для вложенных атрибутов и padding() для заполнения.

• Функции encode(), decode(), encode_raw() и decode_raw() — служат для кодирования и декодирования потоков данных StreamOut и StreamIn.

Класс NetlinkSocket имеет следующие методы:

# Добавить сокет в группу с идентификатором id.

def add_membership(self, id)

# Принять данные, создать экземпляр сообщения NetlinkMessage и отправить его

# в канал Trio.

async def start(self)

# Отправить данные в сокет.

async def send(self, data)

# Принять данные, отправленные в методе start(), из канала Trio.

async def receive(self)

# Создать запрос Netlink, отправить его и получить ответ.

async def request(self, type, payload=b'', flags=0)

# Создать запрос NLMSG_NOOP.

async def noop(self)

Другие модули предоставляют API для работы с различными типами сокетов Netlink:

• netlink.generic — основной протокол сокетов Netlink:

• Класс Family — семейства Netlink, возвращаемые методом GenericNetlinkController.get_families().

• Классы Policy и CommandPolicy — объекты класса Policy возвращаются методами GenericNetlinkController.

• Класс GenericNetlinkMessage — класс сообщения, содержащий атрибуты family, flags, type, version, attributes.

• Класс GenericNetlinkReceiver — используется сокетом для получения сообщений.

• Класс GenericNetlinkSocket — класс сокета.

• Класс GenericNetlinkController(GenericNetlinkSocket) — контроллер, дающий возможность получать семейства и политики.

• netlink.nl80211 — содержит класс NL80211.

• netlink.route — сокеты RTNetlink. Содержит класс RouteController(generic.GenericNetlinkSocket), который реализует функции RTNetlink. Класс предоставляет методы add_address(), add_neighbour() и remove_neighbour().

Каждый из модулей содержит множество атрибутов и соответствующую классу поддерживаемых сокетов функцию connect().

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

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

Резюме

Для упрощения работы c Netlink существует несколько библиотек, предоставляющих различную степень абстракции: libnl, libmnl, libnetlink.

Пакет libnl предлагает большой набор библиотек, предоставляющий разработчикам возможность взаимодействовать с сетевыми подсистемами Linux через интерфейс API Netlink:

• libnl-core — основная библиотека. Содержит функции для построения и анализа сообщений Netlink, работы с Netlink-сокетами, отправки и получения данных.

• libnl-route — предоставляет API для работы с RTNetlink.

• libnl-genl — API протокола Generic Netlink.

• libnl-nf — настройка сетевых фильтров и интерфейсов мониторинга, отслеживание соединений, кэши, очереди.

• libnl-idiag — поддержка для работы с сокетами типа SOCK_DIAG. Функции для мониторинга и управления состоянием сокетов.

Библиотека libmnl является более маленькой и простой и предоставляет базовые функции для проверки и построения заголовков Netlink, а также для анализа ответов. Библиотека дает возможность упростить работу, почти не внося дополнительных расходов.

Библиотека libnetlink, которая используется в пакете iproute2, предоставляет интерфейс высокого уровня для доступа к RTNetlink.

Для работы с Netlink из Python используются пакеты pyroute2 и python-netlink.

Пакет Pyroute2 предоставляет высокоуровневый интерфейс. Он позволяет взаимодействовать с сетевыми интерфейсами и маршрутизацией без необходимости погружения в низкоуровневые детали и предлагает механизм транзакций.

Python-netlink — это относительно маленькая библиотека, обеспечивающая низкоуровневое взаимодействие с сокетами Netlink. Она является асинхронной и предоставляет более простой интерфейс.

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

1. Какие функции и возможности предоставляет библиотека libnl для работы с сетевыми интерфейсами?

2. Какие библиотеки входят в состав libnl?

3. Какой компонент библиотеки libnl предоставляет API для работы с функциями обратного вызова?

4. Как создать сокет Netlink в libnl?

5. Как сконструировать и отправить сообщение в libnl?

6. Как в libnl разобрать ответ ядра?

7. В чем отличие библиотеки libmnl от libnl?

8. Как создать Netlink-сокет с помощью libmnl?

9. Всегда ли батч в libmnl занимает полный буфер?

10. Каков процесс разбора атрибутов в libmnl?

11. Что такое и где используется libnetlink?

12. Рекомендуется ли разработчикам использовать libnetlink в своих продуктах?

13. В каких ОС работает Pyroute2?

14. Из чего состоит Pyroute2?

15. Как называется основной класс в Pyroute2? Что этот класс позволяет делать?

16. Как создать объект для работы с сетевыми интерфейсами в Pyroute2?

17. Как добавить новый сетевой интерфейс с использованием Pyroute2?

18. Где можно найти документацию и примеры использования Pyroute2?

19. Что такое NDB и зачем он нужен?

20. Как работает NDB?

21. Какие модули предлагает пакет Pyroute2?

22. Что такое пакет Python-netlink? Из чего он состоит?

23. В примере библиотеки libnl мы активировали или деактивировали интерфейс. Добавьте установку нового значения MTU с помощью libnl-route.

24. Считайте значение MTU, используя библиотеку Python-netlink и Pyroute2.

56 https://www.infradead.org/~tgr/libnl/

57 https://www.infradead.org/~tgr/libnl/doc/core.html

58 https://github.com/openwrt/libnl-tiny

59 https://www.netfilter.org/projects/libmnl/index.html

60 https://pyroute2.org/

61 https://github.com/kinnay/netlink

Глава 13. Специальные файловые системы

Основная причина, по которой raw-устройства отсутствуют [в Linux], заключается в том, что лично я считаю, что raw-устройства — это глупость.

Линус Торвальдс, linux-kernel mailing list, 1996

Введение

В Unix-подобных ОС с самого начала была реализована введенная Bell Labs концепция «Everything is a file», или «Все есть файл».

Файлами являются даже параметры конфигурации устройств и настройки отдельных соединений. Такие файлы хранятся в специальных файловых системах, например DevFS и ProcFS. Файлы в них — это не отображение записей реальной файловой системы, которая хранит их на постоянном носителе. Это точки входа, записывая в которые можно обращаться либо к ядру, либо к драйверам устройств, или точки входа, читая из которых можно получать данные с устройств.

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

Обычно этот вариант применяется в скриптах, особенно в скриптах оболочки, например Bash. Но иногда возникает желание использовать такой способ в приложениях. Однако в программировании на языках, подобных C++, этот способ взаимодействия используется редко по ряду причин:

• Данные файловые системы различаются между ОС, то есть переносимость таких настроек плохая даже между Unix-подобными системами. Мало того, различия могут быть даже между разными дистрибутивами Linux, потому что содержимое данных файловых систем (ФС) не стандартизовано или стандартизовано недостаточно.

• В ОС семейства Windows /proc и /sys, очевидно, нет.

• Такой способ доступа к параметрам добавляет еще один слой абстракции, что создает дополнительные накладные расходы, а значит, не слишком эффективно.

• Для большинства языков такой подход не является естественным, и запись в файл для установки параметра устройства воспринимается как нечто чужеродное.

Впрочем, некоторые приложения активно используют данные возможности. Например, Linux Netstat из пакета net-tools работает, читая /proc. Хотя на смену Netstat приходит утилита SS, которая этого не делает.

Записи в файлы специальных ФС приложения должны избегать — в основном потому, что приложение не должно конфигурировать систему. Этим должен заниматься системный администратор, который лучше знает, как настроить ОС в каждом конкретном случае. И если он явно не запрашивает конфигурацию, обычно ее делать нельзя.

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

• Приложение для оптимизации конфигурации системы по определенным критериям. Это может быть повышение безопасности, пропускной способности и т.п. Очевидно, что основная задача такого приложения — настройка сетевого стека системы.

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

• Приложение, которое не может работать без выполнения специальных действий. Например, менеджер виртуальных машин, который строит между ними сеть на базе TUN- и TAP-устройств. В этом случае ему просто необходимо добавлять и удалять такие устройства.

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

В этой главе мы рассмотрим назначение специальных ФС, например DevFS или ProcFS. Все они описаны в стандарте UNIX Filesystem Hierarchy Standard. Его полезно изучить, чтобы понимать назначение различных каталогов, точек монтирования и файловых систем в Unix-подобных ОС, включая Linux. Однако стоит также учитывать, что нестандартизованные ФС тоже существуют.

Внимание! Данная книга не посвящена сетевому администрированию. Поэтому не стоит ожидать в ней подробного рассмотрения указанных файловых систем. Их возможности будут упомянуты исключительно в контексте разработки сетевых приложений.

DevFS

Файловая система устройств DevFS — виртуальная файловая система, которая содержит псевдофайлы устройств, что дает процессам в пользовательском пространстве доступ к устройствам и некоторым параметрам работающего ядра ОС.

Работа со всеми ФС начинается с монтирования ФС.

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

Любая примонтированная ФС с типом devtmpfs создает устройства автоматически и не требует явного создания через вызов mknod(), хотя может понадобиться ее дополнительная настройка через udev.

Все POSIX-совместимые ОС содержат каталог /dev, в который монтируется данная файловая система:

dev on /dev type devtmpfs

(rw,nosuid,relatime,size=8086776k,nr_inodes=2021694,mode=755,inode64)

В современных ОС все устройства, подключенные к системе и опознанные соответствующим драйвером, представлены в DevFS, кроме сетевых. Сетевые интерфейсы существуют в своем собственном пространстве имен и экспортируют набор операций, отличный от операций для большинства устройств.

Основная тому причина — историческая. Многие устройства ведут себя как файлы, поэтому концепция «Все есть файл» должна была породить красивую, чистую, поддающуюся сопровождению парадигму операций.

Сокеты и, соответственно, управление сетевыми устройствами были реализованы позже в Калифорнийском университете в Беркли. Разработчикам из Беркли нужно было добавить интернет-функции, которые в концепцию символьных и блочных устройств вписывались не очень хорошо.

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

Другая причина в том, что сетевые приложения более сложны, чем приложения для доступа к файлам, а сетевые адаптеры сложнее многих устройств:

• Они работают не с байтами, а с пакетами и дейтаграммами.

• Хотя обмен данными, как правило, осуществляется с помощью вызовов, аналогичных чтению и записи, для установления соединения требуется больше информации, чем просто имя файла. Например, прослушивание TCP-соединений состоит из двух шагов: один должен выполняться, когда сервер начинает прослушивание, а другой — каждый раз, когда подключается клиент.

• «Обычный API», реализующий чтение и запись, может быть реализован, но, вероятно, не будет удобен. Каждый вызов записи будет отправлять пакет, а каждый вызов чтения будет его получать. Если же буфер слишком мал для пакета, этот пакет будет потерян.

• Большинство сетевых приложений не работают с конкретными сетевыми устройствами, они работают на более высоком уровне, который предоставляют транспортные протоколы. Например, веб-браузер устанавливает TCP-соединение к веб-серверу. Через какое устройство будет идти соединение, не важно. Это должен настраивать администратор для всех приложений.

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

Возможно, для цели обмена данными были бы полезны, например, устройства для сетевых протоколов высокого уровня.

Однако, как уже говорилось, существует ОС Plan 9, в которой все является файлом. В Plan 9 операции для сетевых устройств, подобные fcntl() либо ioctl(), доступны путем чтения и записи в специальные файлы, аналогичные файлам устройств.

Для Linux применимо решение Glendix, доступное на www.glendix.org, — проект, который предлагает порт виртуальной файловой системы /net от Plan9.

Версии ядра Linux до 1.0 создавали сетевые устройства в /dev для целей управления. Скрипт MAKEDEV 1994 года поддерживает /dev/ne[0-3] — NE2000 и другие сетевые адаптеры.

Развитие DevFS

Существовало несколько вариантов поддержки устройств в Unix-подобных ОС и в Linux в частности. Один из первых — скрипт MAKEDEV, который через вызов утилиты mknod(), работающей поверх одноименного системного вызова, создавал большой набор файлов устройств.

Например, чтобы создать управляющее TUN/TAP устройство, нужно вызвать:

mknod /dev/net/tun c 10 200

Будет создан файл символьного устройства с major-номером 10 и minor-номером 200.

Скрипт не проверял, существуют ли реально эти устройства. Если пользователь обращался к несуществующему устройству, система просто выдавала ошибку. Если устройство добавлялось или удалялось, требовалось запустить скрипт MAKEDEV вручную.

В то время /dev был лишь подкаталогом корневой файловой системы и созданные файлы устройств существовали там всегда. Драйвер устройства выбирался на основе его major-номера.

В Linux такой подход использовался до версии ядра 2.4. Ненужные файлы забивали /dev, путали пользователя, создавали проблемы с безопасностью, а major-номеров, которых было всего 255, не хватало. Кроме того, Linux становилась все более «пользовательской» системой, и нужно было поддержать динамическое извлечение и добавление устройств.

Проблему решила DevFS, но не та, которая используется в современных ядрах, а ее первый вариант. Она позволила драйверу создавать устройство через вызов devfs_register. С ее приходом в /dev перестали создаваться несуществующие устройства, а драйверы смогли устанавливать права на создаваемые ими файлы устройств. Был реализован демон devfsd. Этот демон создавал символические ссылки на файлы устройств, однако сами файлы не трогал.

Такой подход, решив существующие проблемы, создал новые:

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

• В результате устройства не имели предсказуемых имен: имена зависели от последовательности их подключения и еще некоторых факторов.

• Все существующие имена приходилось хранить в невыгружаемой памяти ядра.

• Пользовательские скрипты при загрузке должны были ожидать заполнения иерархии и как-то синхронизироваться.

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

Эти проблемы начали решать с версии ядра 2.6. Первым шагом была реализация SysFS. В ней стали отображаться поддерживаемые драйверами устройства. Общий код создавал структуру каталогов с настройками устройств на основе структур данных в драйвере.

Следующим шагом была реализация демона udevd, который, используя SysFS и набор правил конфигурации, формировал актуальную иерархию устройств в /dev.

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

В /dev монтировался обычный TmpFS.

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

Это было неприемлемо для встраиваемых систем и аварийных режимов системы, в которых доступен только Initramfs, поэтому в современной Linux реализован гибридный подход:

• В /dev монтируется DevTmpFS, которая сразу отображает все перечисленные драйверами устройства.

• Если демон udevd меняет какой-либо из файлов, демон становится ответственным за него и управляет им сам. Так же, как он бы это делал с обычным TmpFS.

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

Также существует достаточно старый интерфейс Transport Layer Interface, или TLI, который мы упоминали в главе 1. В нем для того, чтобы получить сокет соответствующего семейства протоколов, необходимо было открыть специальный файл: /dev/ip, /dev/tcp или /dev/udp.

Другой класс устройств, которые обычно не имеют записей в /dev в Linux, — видеоадаптеры. Хотя в некоторых других вариантах Unix они есть. В принципе, простые видеоадаптеры могут быть представлены как устройства кадрового буфера, то есть блочные устройства, в которых блок представляет цвет каждого пикселя.

Ускоренные видеоадаптеры можно представить как символьные устройства, на которые приложения посылают команды.

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

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

В Linux тоже есть устройства /dev/tcp и /dev/udp, в большинстве ядер они отключены. Рассмотрим пример использования такого устройства.

Сначала устройство необходимо создать:

➭ sudo mknod /dev/tcp c 30 36

После этого его можно использовать в скриптах:

#!/bin/bash

TCP_HOST=google.com

TCP_PORT=80

# Открыть файл на ввод и вывод, связав его с дескриптором 3,

# и перезапустить скрипт.

exec 3<>"/dev/tcp/${TCP_HOST}/${TCP_PORT}"

# Отправить запрос в дескриптор.

echo -e "HEAD / HTTP/1.0\r\nHost: ${TCP_HOST}\r\nConnection: close\r\n\r\n" \

>&3

# Прочитать ответ из дескриптора.

cat <&3

В результате получим ответ:

➭ src/book01/ch13/shell/dev-tcp.sh

HTTP/1.0 301 Moved Permanently

Location: http://www.google.com/

Content-Type: text/html; charset=UTF-8

Content-Security-Policy-Report-Only: object-src 'none'; ... https://csp.withgoogle.com/csp/gws/other-hp

Date: Tue, 25 Jun 2024 04:46:38 GMT

Expires: Thu, 25 Jul 2024 04:46:38 GMT

Cache-Control: public, max-age=2592000

Server: gws

Content-Length: 219

X-XSS-Protection: 0

X-Frame-Options: SAMEORIGIN

Получается, что для сети файловый интерфейс вполне применим. Но в том же Bash легко обойтись и без него: в нем хватает сетевых утилит. Например, в некоторых современных дистрибутивах запрос GET выполнить очень просто. Для этого достаточно набрать в консоли:

➭ GET google.com

<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="ru"><head><meta content

...

Видим, что сервер вернул HTML-страницу. Аналогично выполняются POST, HEAD и некоторые другие HTTP-запросы. В данном случае GET и прочие — это утилиты из пакета libwww-perl или perl-libwww — в разных дистрибутивах он называется по-разному.

В скриптах чаще всего используется URL. Большинству разработчиков и администраторов этот вариант понятнее, чем использование сетевого файлового интерфейса, и он гораздо лучше переносим.

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

Также очевидно, что теперь, когда интерфейс устоялся, никакого смысла добавлять сетевые устройства в /dev или переписывать работающий код нет. Однако это не означает, что сетевые устройства не могут находиться в /dev/: любой конкретный драйвер может создать узел устройства для того, чтобы пользователь мог с ним взаимодействовать через ioctl(). Например, существует каталог /dev/net, в котором находятся устройства для управления TAP/TUN-интерфейсами, PPP и сетевые блочные устройства:

➭ ls -l /dev/net/

crw-rw-rw- 1 root root tun

Устройство tun, показанное выше, может быть использовано для создания новых интерфейсов.

Делается это примерно следующим образом:

...

// Тип параметра для ioctl.

ifreq ifr = {};

// Устройство открывается для того, чтобы выполнить ioctl над дескриптором.

int dev_fd = open("/dev/net/tun", O_RDWR);

if (-1 == dev_fd)

{

perror("Opening /dev/net/tun");

}

// Тип, имя, флаги создаваемого виртуального интерфейса.

ifr.ifr_flags = IFF_TUN;

std::copy_if(dev_name.cbegin(), dev_name.cend(), ifr.ifr_name,

[](char c) { return c != '\0'; });

// Выполнить ioctl над дескриптором устройства:

if (-1 == ioctl(dev_fd, TUNSETIFF, &ifr))

{

perror("TUNSETIFF ioctl");

close(dev_fd);

}

...

О TUN/TAP мы более подробно будем говорить при описании перехвата данных в главе 23.

FDescFS

FDescFS — это специальная файловая система FreeBSD, которая монтируется в /dev/fd и предоставляет доступ к файловым дескрипторам каждого процесса. Ее альтернатива в Linux — каталог /proc/self/fd, который может быть полезен для работы с дескрипторами процесса.

Содержимое FDescFS отображается как список нумерованных файлов, соответствующих открытым файлам процесса, читающего смонтированный каталог. Файлы имеют шаблон имени типа /dev/fd/n, где n — число, которое ссылается на файловые дескрипторы. Например, /dev/fd/0.

ProcFS

Рис. 13.1. Схема каталогов файловой системы ProcFS

ProcFS — это виртуальная файловая система, которая используется для представления информации о работающих процессах и параметрах структур данных ядра через файлы. Основные ее каталоги показаны на рис. 13.1.

Обычно монтируется в каталог /proc:

proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)

В первых версиях Unix-программы узнавали о запущенных процессах в системе напрямую, читая структуры из памяти ядра. Например, первые версии команды ps читали псевдофайл /dev/mem и разбирали необработанные данные, но выставлять системные данные непосредственно в пользовательское пространство через /dev/mem достаточно небезопасно.

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

ProcFS, которая появилась в Unix System V, содержала информацию о каждом запущенном в данный момент процессе, которую можно было получить, используя схему /proc/$PID/stuff. Интерфейсы и структуры каталогов могли оставаться прежними, даже если структуры данных в ядре изменялись.

К процессу, из которого делается запрос к /proc, можно обратиться через симлинк self:

➭ ls -l /proc/self

lrwxrwxrwx 1 root root 0 мая 22 00:57 /proc/self -> 991517

Или через его PID, который можно получить, вызвав функцию getpid() либо использовав специальную переменную с именем $$ в оболочке.

Linux расширил содержимое /proc, добавив информацию о состоянии ядра и настройках.

Внимание! ProcFS не стандартизован! В некоторых старых BSD-системах он вообще отсутствует.

Чтение настроек сети через ProcFS

В /proc содержатся информационные файлы, доступные только для чтения, и файлы, доступные для записи, которые могут изменять состояние ядра. С точки зрения сетевого программирования нас прежде всего интересует каталог /proc/self/net или /proc/net, который является ссылкой на self:

➭ ls -ld /proc/net

lrwxrwxrwx 1 root root 8 июл 29 21:39 /proc/net -> self/net

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

➭ cat /proc/net/dev

Inter-| Receive                                                  | Transmit

face  | bytes   packets errs drop fifo frame compressed multicast| bytes

lo:  9809906   98481    0    0    0     0          0         0   9809906

eno2:        0       0    0    0    0     0          0         0         0

wlo1: 17673001  155075    0    0    0     0          0         0  43285148

IPv6-адреса можно прочитать из /proc/net/if_inet6:

➭ cat /proc/net/if_inet6

fe800000000000006072680ebbd12345 03 40 20 80     wlo1

00000000000000000000000000000001 01 80 10 80       lo

fe80000000000000004207fffe912345 04 40 20 80  docker0

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

Для записи доступен pktgen — генератор пакетов, о котором мы поговорим далее.

Посмотрим, что вообще может содержать /proc:

• net/arp — выводит список ARP-таблиц ядра. Этот файл полезен для получения ассоциации аппаратных адресов с IP-адресами.

• net/atm/ — файлы в этом каталоге содержат настройки и статистику ATM — асинхронного режима передачи. Этот каталог в основном используется, например, для ADSL-подключений.

• net/dev — показанный выше список различных сетевых устройств и статистика приема и передачи данных.

• net/dev_mcast — список многоадресных групп, которые прослушивает каждое из устройств.

• net/igmp — список многоадресных IP-адресов, к которым присоединилась эта система.

• net/ip_mr_cache — список кэшей многоадресной маршрутизации.

• net/ip_mr_vif — список многоадресных виртуальных интерфейсов.

• net/netfilter/nfnetlink_queue — информация об очередях Netfilter, если он включен.

• net/netstat — подробный список сетевой статистики: тайм-ауты TCP, отправленные и полученные SYN-cookie и т.п.

• net/psched — глобальные параметры планировщика пакетов.

• net/rarp — содержит базу данных RARP, если RARP был включен при сборке ядра.

• net/raw — необработанная статистика устройства.

• net/route — таблица маршрутизации ядра.

• net/rt_cache — текущий кэш маршрутизации.

• net/snmp — список данных SNMP для используемых сетевых протоколов.

• net/sockstat — общая статистика по сокетам.

• net/ip_tables_names — список используемых типов IPTables. Содержит одно или несколько из следующих значений: filter, mangle или nat. Существует только в случае, если загружен iptables.

• net/tcp — подробная информация о TCP-сокетах.

• net/udp — подробная информация о UDP-сокетах.

• net/unix — список используемых сокетов домена UNIX.

• net/wireless — статистика беспроводных интерфейсов.

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

В старых версиях ядра Linux ioctl() для получения маршрутов не было. Поэтому в старой программе route из пакета net-tools файлы /proc/net/route, /proc/net/ipv6_route, /proc/net/rt_cache использовались для просмотра маршрутов.

Другие протоколы использовали аналогичные файлы, например /proc/net/x25/route или /proc/net/atalk_route.

Настройка параметров сети через ProcFS

Каталог /proc/sys/ содержит подкаталоги с файлами, управляющими настройками работающего ядра. В большинство файлов данного каталога разрешена запись. Системный администратор через них может включать и выключать функциональные блоки ядра.

Внимание! Изменение настроек системы с помощью файлов в каталоге /proc/sys может сделать ядро нестабильным, что потребует перезагрузки системы. Убедитесь в корректности параметров, прежде чем пытаться изменять какое-либо значение в /proc/sys.

Для нас в этом каталоге интересен подкаталог /proc/sys/net, в котором содержатся настройки сети уровня системы. Например, значение по умолчанию для описанной ранее опции IPV6_V6ONLY определяется содержимым файла /proc/sys/net/ipv6/bindv6only:

# cat /proc/sys/net/ipv6/bindv6only

0

# echo 1 > /proc/sys/net/ipv6/bindv6only

# cat /proc/sys/net/ipv6/bindv6only

1

# echo 0 > /proc/sys/net/ipv6/bindv6only

# cat /proc/sys/net/ipv6/bindv6only

0

Максимально допустимый размер вспомогательного буфера для каждого сокета можно установить с помощью /proc/sys/net/core/optmem_max:

# cat /proc/sys/net/core/optmem_max

20480

# echo 20485 > /proc/sys/net/core/optmem_max

# cat /proc/sys/net/core/optmem_max

20485

Состав каталогов определяется набором модулей и опций времени компиляции ядра.

Некоторые файлы конфигурации в /proc/sys/ содержат более одного значения.

Чтобы правильно установить им новые значения, поместите между ними пробел:

echo "4 2 50" > /proc/sys/kernel/acct.

Каталог /proc/sys/net/core/ содержит файлы для настройки взаимодействия между ядром и сетевыми уровнями разных протоколов.

Наиболее важные из этих файлов:

• message_burst — устанавливает лимит времени в десятых долях секунды для записи нового предупреждающего сообщения из сетевого кода в журнал ядра. Этот параметр используется для смягчения атак типа «отказ в обслуживании». Значение по умолчанию — 50, то есть сообщения будут записываться не чаще одного за 5 секунд.

• message_cost — устанавливает стоимость каждого предупреждающего сообщения. Чем выше значение параметра, тем больше вероятность того, что предупреждающее сообщение будет проигнорировано. Этот параметр тоже используется для защиты от DoS-атак. Значение по умолчанию — 5.

• netdev_max_backlog — устанавливает максимальное количество пакетов, разрешенных в очереди, когда определенный интерфейс получает пакеты быстрее, чем ядро может их обработать. Значение по умолчанию для этого параметра — 300.

• optmem_max — максимальный размер вспомогательного буфера, разрешенный для каждого сокета.

• rmem_default — размер буфера приема сокета по умолчанию в байтах.

• rmem_max — максимальный размер буфера приема сокета в байтах.

• wmem_default — размер буфера передачи сокета по умолчанию в байтах.

• wmem_max — максимальный размер буфера передачи сокета в байтах.

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

Каталоги /proc/sys/net/ipv4/ и /proc/sys/net/ipv6/ содержат дополнительные сетевые настройки протоколов IPv4 и IPv6 соответственно.

Рассмотрим несколько файлов из каталога /proc/sys/net/ipv4/:

• icmp_destunreach_rate, icmp_echoreply_rate, icmp_paramrob_rate и icmp_timeexeed_rate — максимальная скорость отправки пакетов ICMP на хосты при определенных условиях. Значение 0 выключает задержку.

• icmp_echo_ignore_all и icmp_echo_ignore_broadcasts — ядро будет игнорировать пакеты ICMP ECHO, то есть ping-запросы от каждого хоста или только те, которые исходят с широковещательных и многоадресных адресов соответственно, если значение 1 параметра запрещает ядру отвечать на ping-запросы.

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

• ip_forward — разрешить интерфейсам в системе пересылать пакеты друг другу. По умолчанию для этого файла установлено значение 0. Запись в этот файл значения 1 включает пересылку.

• ip_local_port_range — диапазон портов, которые будут использоваться TCP или UDP для эфемерных портов. Первое число указывает младший порт, а второе — старший порт. Любые системы, которым требуется больше портов, чем по умолчанию от 1024 до 4999, должны использовать диапазон от 32 768 до 61 000.

• tcp_fin_timeout — значение по умолчанию для опции TCP_LINGER2.

• tcp_retries1 — количество разрешенных повторных передач для ответа на входящее соединение. По умолчанию 3.

• tcp_retries2 — количество разрешенных повторных передач TCP-пакетов. По умолчанию 15.

• tcp_syn_retries — ограничение на количество повторных передач SYN пакета при установке соединения.

В каталогах /proc/sys/net/ipv4/ и /proc/sys/net/ipv6/ существует ряд других каталогов, каждый из которых охватывает отдельный аспект сетевого стека:

• conf/ позволяет настраивать каждый системный интерфейс отдельно:

• conf/default/ — настройки по умолчанию для несконфигурированных устройств.

• conf/all/ — настройки, переопределяющие все специальные конфигурации.

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

• route/ — настройки, отвечающие за маршрутизацию. В отличие от conf/ и neigh/ данный каталог применим к маршрутизации через любые интерфейсы в системе. Многие из этих параметров, например max_size, max_delay и min_delay, относятся к управлению размером кэша маршрутизации.

Обратите внимание, что списки параметров конфигурации интерфейсов для IPv4 и IPv6 различаются:

/proc/sys/net/ipv4/conf/eno2

/proc/sys/net/ipv6/conf/eno2

accept_local

accept_redirects

accept_source_route

arp_accept

arp_announce

arp_filter

arp_ignore

arp_notify

bc_forwarding

bootp_relay

disable_policy

disable_xfrm

drop_gratuitous_arp

drop_unicast_in_l2_multicast

force_igmp_version

forwarding

igmpv2_unsolicited_report_interval

igmpv3_unsolicited_report_interval

ignore_routes_with_linkdown

log_martians

mc_forwarding

medium_id

accept_dad

accept_ra

accept_ra_defrtr

accept_ra_from_local

accept_ra_min_hop_limit

accept_ra_mtu

accept_ra_pinfo

accept_ra_rt_info_max_plen

accept_ra_rt_info_min_plen

accept_ra_rtr_pref

accept_redirects

accept_source_route

addr_gen_mode

autoconf

dad_transmits

disable_ipv6

disable_policy

drop_unicast_in_l2_multicast

drop_unsolicited_na

enhanced_dad

force_mld_version

force_tllao

promote_secondaries

proxy_arp

proxy_arp_pvlan

route_localnet

rp_filter

secure_redirects

send_redirects

shared_media

src_valid_mark

tag

forwarding

hop_limit

ignore_routes_with_linkdown

ioam6_enabled

ioam6_id

ioam6_id_wide

keep_addr_on_down

max_addresses

max_desync_factor

mc_forwarding

mldv1_unsolicited_report_interval

mldv2_unsolicited_report_interval

mtu

ndisc_notify

ndisc_tclass

optimistic_dad

proxy_ndp

ra_defrtr_metric

regen_max_retry

router_probe_interval

router_solicitation_delay

router_solicitation_interval

router_solicitation_max_interval

router_solicitations

rpl_seg_enabled

seg6_enabled

seg6_require_hmac

stable_secret

suppress_frag_ndisc

temp_prefered_lft

temp_valid_lft

use_oif_addrs_only

use_optimistic

use_tempaddr

Например, разрешение на пересылку дейтаграмм через параметр forwarding на конкретном адаптере можно настроить отдельно для IPv4 и IPv6.

Для IPv6 также существует каталог /proc/sys/net/ipv6/icmp, в котором хранятся настройки ICMP6-протокола. Для IPv4 они разбросаны по файлам в каталоге /proc/sys/net/ipv4.

Для сокетов типа AF_UNIX настройки хранятся в нескольких каталогах:

• /proc/sys/net/unix/ — общие настройки сокета, например максимальный размер дейтаграммы, max_dgram_len.

• /proc/sys/fs — в этом каталоге настройки каналов начинаются с префикса pipe-. Например /proc/sys/fs/pipe-max-size — предел, до которого непривилегированный процесс может подстроить емкость буфера канала.

Дополнительную информацию о каталогах и значениях конфигурационных файлов в ProcFS возможно найти в man 7 ip, man 5 proc, man 5 procfs и в документации по ядру: filesystems/proc.txt, networking/ip-sysctl.txt.

SysFS

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

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

Структура драйверов в ядре автоматически создает каталоги в SysFS, показанные на рис. 13.2, при регистрации драйверов, на основе типа драйвера и значений в их структурах данных.

Рис. 13.2. Схема каталогов и файловой системы SysFS и связанных структур

Все драйверы определенного типа будут иметь одни и те же элементы, доступные через SysFS.

Информация об инициализированных устройствах сгруппирована по разным критериям: типам устройств, шинам, протоколам и т.п.

В разных каталогах существуют символические ссылки на различные устройства.

SysFS монтируется в каталог /sys:

sys on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)

Данная ФС была реализована с целью выделения параметров устройств из ProcFS, которая исторически предназначалась для получения доступа к дереву процессов и должна выполнять именно эту задачу. Но когда в Linux начали добавлять в ProcFS параметры ядра, никакого стандарта еще не существовало. Поэтому /proc вскоре превратился в свалку атрибутов устройств, распределенным по разным подкаталогам.

Начиная с ядра Linux версии 2.6, параметры ядра, связанные с драйверами устройств и файловыми системами, были вынесены в каталог /sys.

Таким образом, SysFS — это попытка разработчиков ядра придать структуру настройкам. В идеале вся информация, не относящаяся к процессам, должна быть вынесена из /proc в /sys, но для совместимости в /proc сохранилось много разных настроек. Поэтому нередко есть два способа внести изменения в работающее ядро:

• Старый способ — через /proc.

• Новый способ — через /sys, который предпочтительно использовать в новом коде.

Подытожим отличия между двумя ФС:

• SysFS содержит более подробную информацию об устройствах. Положение узлов представляет иерархию устройств по подсистемам. Для каждого объекта в модели драйвера создается каталог.

• SysFS более строго организована, чем /proc, так как начиналась с более строгого дизайна, и ей не требуется поддерживать устаревшую функциональность, как это делается в ProcFS. Каждый файл в SysFS содержит значение одного атрибута.

• ProcFS допускает произвольные файловые операции, SysFS более ограниченна. Записи ProcFS получают структуру file_operations, которая содержит указатели на функции, запускаемые файловыми системными вызовами. Например: open(), read(), mmap() и т.д., и с ними можно выполнять произвольные действия. В SysFS для работы с атрибутами используются методы show() и store().

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

/sys/bus

Содержит список подкаталогов, каждый из которых представляет тип физической шины, поддерживаемой ядром: USB, PCI, SCSI и т.д.

Каждый тип шины содержит подкаталоги devices и drivers:

• devices — список устройств, обнаруженных в настоящее время или привязанных к данному типу шины. Каждый файл в каталоге является символической ссылкой на файл устройства в подкаталогах /sys/devices.

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

/sys/devices

Содержит глобальную иерархию устройств с информацией для каждого физического и виртуального устройства, которое обнаружено ядром.

Основной каталог, на который ссылаются подкаталоги в каталогах классификаторов:

• /sys/block — драйверы блочных устройств. Символические ссылки в этом каталоге — имена устройств, например dm-0, loop1, nvme0n1 и т.д.

• /sys/class — классификация по типу устройства, зарегистрированному в ядре. Класс устройства описывает функциональный тип устройства. Каждый каталог класса содержит подкаталоги символических ссылок на зарегистрированные в этом классе устройства.

• /sys/dev — классификация по типу и значениям major, minor устройства. Внутри содержит каталоги block и char, в которых устройства представлены как символические ссылки вида major:minor.

Посмотрим на каталог /sys/class/net/, в котором представлена конфигурация и статистика различных сетевых устройств:

➭ ls -l /sys/class/net

docker0 -> ../../devices/virtual/net/docker0

eno2 -> ../../devices/pci0000:00/0000:00:1f.6/net/eno2

lo -> ../../devices/virtual/net/lo

tun0 -> ../../devices/virtual/net/tun0

wlo1 -> ../../devices/pci0000:00/0000:00:14.3/net/wlo1

Подкаталоги — устройства, а файлы подкаталогов — их параметры:

➭ ls -l /sys/class/net/eno2/

-r--r--r-- 1 root root addr_assign_type

-r--r--r-- 1 root root address

-r--r--r-- 1 root root addr_len

-r--r--r-- 1 root root broadcast

-rw-r--r-- 1 root root carrier

-r--r--r-- 1 root root carrier_changes

-r--r--r-- 1 root root carrier_down_count

-r--r--r-- 1 root root carrier_up_count

lrwxrwxrwx 1 root root device -> ../../../0000:00:1f.6

-r--r--r-- 1 root root dev_id

-r--r--r-- 1 root root dev_port

-r--r--r-- 1 root root dormant

-r--r--r-- 1 root root duplex

-rw-r--r-- 1 root root flags

-rw-r--r-- 1 root root gro_flush_timeout

-rw-r--r-- 1 root root ifalias

-r--r--r-- 1 root root ifindex

-r--r--r-- 1 root root iflink

-r--r--r-- 1 root root link_mode

-rw-r--r-- 1 root root mtu

-r--r--r-- 1 root root name_assign_type

-rw-r--r-- 1 root root napi_defer_hard_irqs

-rw-r--r-- 1 root root netdev_group

-r--r--r-- 1 root root operstate

-r--r--r-- 1 root root phys_port_id

-r--r--r-- 1 root root phys_port_name

-r--r--r-- 1 root root phys_switch_id

drwxr-xr-x 2 root root power

-rw-r--r-- 1 root root proto_down

drwxr-xr-x 4 root root queues

-r--r--r-- 1 root root speed

drwxr-xr-x 2 root root statistics

lrwxrwxrwx 1 root root subsystem -> ../../../../../class/net

-r--r--r-- 1 root root testing

-rw-r--r-- 1 root root threaded

-rw-r--r-- 1 root root tx_queue_len

-r--r--r-- 1 root root type

-rw-r--r-- 1 root root uevent

➭ cat /sys/class/net/eno2/mtu

1500

Видно, например, что, используя SysFS, можно получить и установить MTU сетевого адаптера.

Эту же настройку возможно посмотреть и через ProcFS, используя каталог /proc/sys/net/ipv6/conf/eno2/mtu. Но установить ее через ProcFS нельзя, только через SysFS.

/sys/firmware

Каталог прошивки содержит интерфейсы для просмотра и управления прошивкой для конкретной платформы. В /sys/firmware/efi, например, содержатся конфигурационные данные UEFI.

/sys/fs

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

Каталоги содержат настройки для каждой из файловых систем, а также общие настройки. Там же содержатся каталоги для FUSE, контрольных групп BPF и т.д.

/sys/module

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

/sys/kernel

Файлы и подкаталоги с информацией о работающем ядре: подсистема управления памятью, контрольные группы и т.п.

Содержит точки монтирования файловых систем TraceFS и DebugFS.

/sys/power

Управление питанием всей системы и устройств.

DebugFS

DebugFS — специальная файловая система, разработанная для целей отладки. Она дает возможность сделать информацию ядра доступной в пользовательском пространстве.

В отличие от ProcFS, которая предназначена для получения информации о процессах, или SysFS, которая организована по строгим правилам с одним значением для каждого файла, для DebugFS правила организации отсутствуют. Разработчики драйвера могут поместить туда любую информацию, которую захотят.

Обычно монтируется в /sys/kernel/debug:

debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime)

Из данных, содержащихся в этой ФС, прикладной разработчик может получить массу информации о драйверах, которую не показывают другие средства. Например, данные от драйвера Wi-Fi-адаптера:

# ls -1 /sys/kernel/debug/ieee80211/phy0/

aql_enable

aql_threshold

aql_txq_limit

aqm

force_tx_status

fragmentation_threshold

ht40allow_map

hw_conf

hwflags

iwlwifi -> ../../iwlwifi/0000:00:14.3

keys

long_retry_limit

misc

netdev:p2p-dev-wlo1

netdev:wlo1

power

queues

rate_ctrl_alg

reset

rts_threshold

short_retry_limit

statistics

total_ps_buffered

user_power

wep_iv

Можно получить практически любые сведения, включая ключи или данные подключенной точки доступа. Например, мощность передатчика Wi-Fi или аппаратные флаги, предоставляемые адаптером, если драйвер зарегистрировал все эти параметры:

# cat /sys/kernel/debug/ieee80211/phy0/netdev\:wlo1/txpower

22

# cat /sys/kernel/debug/ieee80211/phy0/hwflags

SIGNAL_DBM

SPECTRUM_MGMT

AMPDU_AGGREGATION

SUPPORTS_PS

SUPPORTS_DYNAMIC_PS

MFP_CAPABLE

WANT_MONITOR_VIF

SUPPORT_FAST_XMIT

REPORTS_TX_ACK_STATUS

CONNECTION_MONITOR

AP_LINK_PS

TIMING_BEACON_ONLY

CHANCTX_STA_CSA

SUPPORTS_CLONED_SKBS

SINGLE_SCAN_ON_ALL_BANDS

TDLS_WIDER_BW

SUPPORTS_AMSDU_IN_AMPDU

NEEDS_UNIQUE_STA_ADDR

SUPPORTS_REORDERING_BUFFER

USES_RSS

TX_AMSDU

TX_FRAG_LIST

DEAUTH_NEED_MGD_TX_PREP

BUFF_MMPDU_TXQ

SUPPORTS_VHT_EXT_NSS_BW

STA_MMPDU_TXQ

Таким образом, ядро может выводить через DebugFS «закрытые» сведения или даже управлять некоторыми внутренними настройками. Поэтому доступ к DebugFS может получить только администратор.

Вот пример принудительного опустошения буфера передачи устройства:

# echo 0 > /sys/kernel/debug/iwlwifi/0000\:00\:14.3/iwlmvm/tx_flush

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

TraceFS

TraceFS предназначена для упрощения доступа из пространства пользователя к данным трассировки ядра Linux.

В большинстве дистрибутивов ФС монтируется в каталог /sys/kernel/tracing, но может быть смонтирована в /sys/kernel/debug/tracing, причем одновременно:

tracefs on /sys/kernel/tracing type tracefs (rw,nosuid,nodev,noexec,relatime)

tracefs on /sys/kernel/debug/tracing type tracefs (rw,...,relatime)

Это одна и та же ФС, просто смонтирована два раза в разные точки.

Она применяется, когда DebugFS не подходит из соображений безопасности. TraceFS позволяет администратору использовать только интерфейс трассировки, без открытия доступа к другим возможностям DebugFS, а также обеспечить поддержку создания буферов трассировки через системные вызовы mkdir и rmdir, что DebugFS не поддерживает.

TraceFS сохраняет обратную совместимость со старыми приложениями, эмулируя поведение DebugFS.

BPFFS

BPFFS — псевдофайловая система, которая позволяет создавать файлы, ссылающиеся на объекты Berkley Packet Filter.

Существует два варианта BPF:

• cBPF, или «классический BPF» — пакетный фильтр, использующий язык фильтрации пакетов, как в tcpdump.

• eBPF — виртуальная машина, которая может исполнять скрипты в пространстве ядра ОС, то есть это не пакетный фильтр.

В ядре Linux реализован именно eBPF. Он совместим с cBPF и транслирует его байт-код в представление eBPF прямо в ядре. Программы eBPF либо интерпретируются, либо JIT-компилируются.

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

Встроенный верификатор выполняет статический анализ кода и отклоняет сбойные и плохо влияющие на работу ядра программы. Например, будут отклонены программы с циклами for/while без условий выхода или программы, разыменовывающие указатели без проверки.

Код BPF запускается, как правило, в случае наступления определенного события и выполняется до своего завершения, что похоже на обработчик прерывания.

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

Код eBPF программ может использовать некоторые функции ядра для выполнения некоторых системных вызовов и получения доступа к структурам ядра.

Файловая система BPF обычно монтируется в /sys/fs/bpf:

bpf on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime,mode=700)

Объекты BPF создаются пользователем при помощи команд системного вызова bpf:

• BPF_PROG_LOAD — проверить и загрузить BPF-программу.

• BPF_MAP_CREATE — создать BPF-карту. Карта — это структурированная область памяти, которую программы BPF могут использовать совместно.

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

Рис. 13.3. Файловая система BPF

Каждый файловый объект, доступный в пространстве пользователя, увеличивает счетчик ссылок объекта BPF в ядре.

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

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

Рассматривать BPF в данной книге мы не будем по соображениям объема, хотя eBPF — очень успешная разработка в Linux и в разном виде она была перенесена в другие операционные системы, например ОС Windows, где похожий инструмент называется WFP.

ConfigFS

ConfigFS была добавлена в ядро Linux в 2005 году, чтобы обеспечить гибкую настройку модулей пользователем без введения новых ioctl() и системных вызовов. Монтируется в /sys/kernel/config. Предназначается для создания и уничтожения объектов ядра из пространства пользователя. По сути, эта ФС в чем-то обратна SysFS, которая используется для просмотра объектов и управления ими. SysFS нежелательно использовать для создания объектов, ибо ее архитектура этого не предполагала.

Объект ядра создается через вызов mkdir и будет создан, если какой-либо модуль ядра поддерживает создание такого объекта. Удаляется объект через rmdir. Созданные «каталоги» содержат файлы-атрибуты, в которые отображается состояние модуля.

Обычно данная ФС пустая. Ее использует небольшое число модулей, например модуль netconsole.

SockFS

Если посмотреть на список всех смонтированных файловых систем, он будет несколько больше показанного командой mount:

➭ cat /proc/filesystems

nodev   sysfs

nodev   tmpfs

nodev   bdev

nodev   proc

nodev   cgroup

nodev   cgroup2

nodev   cpuset

nodev   devtmpfs

nodev   binfmt_misc

nodev   configfs

nodev   debugfs

nodev   tracefs

nodev   securityfs

nodev   sockfs

nodev   bpf

nodev   pipefs

nodev   ramfs

nodev   hugetlbfs

nodev   devpts

nodev   autofs

nodev   efivarfs

nodev   mqueue

nodev   binder

nodev   pstore

ext3

ext2

ext4

fuseblk

nodev   fuse

nodev   fusectl

vfat

squashfs

nodev   overlay

В списке файловых систем можно заметить такие, как bdev, cpuset, securityfs, sockfs, pipefs, mqueue, binder, pstore и подобные. Это служебные файловые системы.

Например, cpuset нужна для того, чтобы связывать наборы процессоров с задачами, mqueue — для поддержки очередей POSIX, и т.д. Мы не будем описывать их все и коснемся только SockFS.

Для записи данных в сокеты Unix-подобных ОС можно использовать системный вызов write()и системный вызов read() — для их чтения. В Linux для этого используется подсистема VFS — виртуальная файловая система. Это модульная инфраструктура, позволяющая регистрировать модули обработки различных файловых систем.

В коде ядра, в файле net/socket.c, содержится функция sock_init(), которая вызывается при инициализации ОС. В функции регистрируется инфраструктура SysFS для управления сокетами, а затем регистрируется и монтируется специальная файловая система SockFS, описанная структурой sock_fs_type:

static struct file_system_type sock_fs_type =

{

.name = "sockfs",

.init_fs_context = sockfs_init_fs_context,

.kill_sb = kill_anon_super,

};

Причем монтируется она без явной точки монтирования.

Системный вызов __sys_socket, рассмотренный в главе 7, выполняет еще несколько операций, которые мы не описывали, что в итоге приводит к сопоставлению с новым сокетом открытого файла, хранящегося в памяти на SockFS.

Вызов делает следующее:

1. Приводит к вызову функции __sock_create(), в которой вызывается функция sock_alloc(), создающая inode сокета в SockFS через вызов alloc_inode().

2. Вызывает функцию sock_map_fd(), которая выполняет следующие действия:

a) вызывает alloc_file_pseudo() для создания нового файла и привязки его к inode. По сути, это вызов alloc_file(). В качестве обработчиков файловых операций передается структура socket_file_ops, которая перенаправляет файловые вызовы на вызовы сокета;

б) вызывает для этого файла stream_open(), что позволяет работать с ним как с открытым файловым потоком;

в) вызывает fd_install(), чтобы установить в таблице дескрипторов указатель на файл.

В итоге, например, запись через write() в дескриптор сокета приводит к записи в псевдофайл и вызову __sock_sendmsg(), который является обработчиком записи.

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

SockFS можно даже монтировать:

➭ mkdir sfs

➭ sudo mount -t sockfs sockfs sfs

➭ ls sfs

ls: невозможно открыть 'sfs': это не каталог

➭ sudo umount sfs

Но она не поддерживает листинг, и польза от ее монтирования будет невелика.

Использование служебной файловой системы для поддержки файловых вызовов на дескрипторе, который файлом не является, не отличительная особенность Linux. Даже в ОС Windows есть похожее средство — файловая система IFS, которая позволяет использовать подмножество файлового API для чтения из сокетов и записи в них.

Каталоги /run и /tmp

Каталог /run содержит настройки работающих приложений и некоторую информацию о работающей системе с момента последней загрузки, например файл utmp с зарегистрированными в системе пользователями. Также в этом каталоге хранит свои настройки демон udev. Настройки ядра ОС данная ФС не содержит.

Обычно в этот каталог монтируется TmpFS, хранящая в оперативной памяти информацию, требуемую на раннем этапе загрузки:

run on /run type tmpfs (rw,nosuid,nodev,relatime,mode=755,inode64)

Каталог /run доступен на запись в процессе загрузки, и его используют системные приложения.

В /run не всегда монтируется tmpfs. Если данный каталог создан в ФС, расположенной в постоянном хранилище, например на диске, файлы в этом каталоге должны быть либо удалены, либо усечены системой в начале процесса загрузки.

ФС будет очищена в случае перезагрузки, но если этого не происходит, файлы в /run не удаляются автоматически при остановке процесса, поэтому сам процесс должен удалить все им созданное, то есть выполнить надлежащую очистку.

Каталог /var/run является символической ссылкой на /run и существует исторически.

На данный момент /var/run считается неправильным расположением и сохраняется для поддержки устаревшего программного обеспечения.

Правильный каталог — /run.

Каталог /tmp доступен на запись всем пользователям. В этом каталоге может сохранять файлы любое приложение.

В случае перезагрузки /tmp будет очищен; кроме того, периодически система может удалять в нем неиспользуемые файлы.

В большинстве современных ОС TmpFS монтируется в /tmp, как и в /run:

tmpfs on /tmp type tmpfs

(rw,nosuid,nodev,size=8100948k,nr_inodes=1048576,inode64)

Внимание! Поскольку /run и /tmp обычно хранятся в памяти, в них желательно не записывать большие файлы, так как память может переполниться. И если для /tmp обычно устанавливается ограничение на максимальный размер, то для /run — нет, то есть он будет расти до исчерпания всей свободной памяти. После этого часть ФС будет сброшена в раздел подкачки, если он подключен, что снизит производительность работы системы в целом.

Если вы пишете системное приложение, храните временные данные в /run/<каталог_приложения>, в ином случае пользуйтесь для хранения /tmp. Всегда при выходе из процесса удаляйте из него все созданные им файлы.

Функции sysctl() и sysconf()

Функцию sysctl() и системный вызов можно встретить в старых версиях Linux. В ядре Linux 5.5 системный вызов sysctl был удален. Поддержка GLibC была удалена в версии 2.32.

sysctl() отвечает за чтение и запись параметров ядра:

#include <unistd.h>

#include <linux/sysctl.h>

int _sysctl(__sysctl_args *args);

Сейчас вместо него рекомендуется использовать /proc/sys. Если вы раньше применяли sysctl(), это тот случай, когда для новых версий ОС придется использовать специальные ФС.

Функция sysconf() нужна для получения параметров системы в процессе выполнения:

#include <unistd.h>

long sysconf(int name);

Параметр name — имя получаемой опции.

Функция позволяет вернуть такие параметры, как максимальное количество файлов, которое может открыть процесс, размер страницы памяти, максимальное количество символов в имени системного пользователя и т.п. Нас же она интересует прежде всего потому, что дает возможность получить максимальное количество буферов, которое может быть использовано такими функциями, как sendmsg(), то есть количество элементов массива msg_ioc в структуре msghdr. Для этого используется опция _SC_IOV_MAX, и в общем случае получение максимального количества буферов выглядит так:

extern "C"

{

// Определить макрос, чтобы включить объявление данной функции.

#if !defined(_XOPEN_SOURCE)

#define _XOPEN_SOURCE

#endif

#include <limits.h>

#include <unistd.h>

}

int main()

{

// Получить значение параметра.

auto buf_count = sysconf(_SC_IOV_MAX);

if (-1 == buf_count)

{

// Не удалось получить через sysconf().

perror("sysconf");

#if defined(IOV_MAX)

// Установим константу.

buf_count = IOV_MAX;

std::cout

<< "Buffers count got via IOV_MAX\n";

#endif

}

else

{

std::cout

<< "Buffers count got via sysconf()\n";

}

#if defined(IOV_MAX)

std::cout

<< "IOV_MAX = " << IOV_MAX << "\n";

#endif

std::cout

<< "Buffers count = "

<< buf_count << std::endl;

return EXIT_SUCCESS;

}

По умолчанию в Linux выделяется 1024 буфера:

➭ build/bin/b01-ch12-sysconf

Buffers count got via sysconf()

IOV_MAX = 1024

Buffers count = 1024

Функция sysconf() предоставляет еще одну полезную возможность — получение размера страницы, что используется, например, в libmnl для установки размера буфера:

#define MNL_SOCKET_BUFFER_SIZE \

(sysconf(_SC_PAGESIZE) < 8192L ? sysconf(_SC_PAGESIZE) : 8192L)

Резюме

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

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

Существует несколько типов виртуальных файловых систем:

• DevFS — содержит псевдофайлы устройств, что дает процессам в пользовательском пространстве доступ к устройствам и некоторым параметрам работающего ядра ОС. Все POSIX-совместимые ОС содержат каталог /dev, в который монтируется данная файловая система. Почти все устройства, которые подключены к системе и опознаны соответствующим драйвером, представлены в DevFS.

• ProcFS — используется для представления информации о работающих процессах и параметрах структур данных ядра через файлы. Обычно монтируется в каталог /proc.

• SysFS и ConfigFS — обеспечивают получение системной информации и единый унифицированный способ доступа к параметрам оборудования, файловых систем, модулей, атрибутам драйверов и настройкам ядра из пользовательского пространства. SysFS монтируется в каталог /sys. Данные ФС были реализованы с целью выделения параметров устройств из ProcFS. Есть два способа внести изменения в работающее ядро: старый способ — через /proc — и новый способ — через /sys или через /sys/kernel/config. Новый способ предпочтительнее.

• DebugFS и TraceFS — используются для целей отладки. Они позволяют разработчикам ядра сделать его информацию доступной в пользовательском пространстве. Обычно монтируется в /sys/kernel/debug.

• BPFFS — позволяет взаимодействовать с Berkley Packet Filter. Файловая система BPF обычно монтируется в /sys/fs/bpf.

Все эти специальные файловые системы играют важную роль в операционных системах Linux, предоставляя доступ к различным системным ресурсам и информации. Они упрощают работу с устройствами, мониторинг процессов и состояния системы, отладку ядра и программ, а также настройку и трассировку системы. Это делает их неотъемлемой частью разработки и администрирования Linux-систем.

Помимо них, приложению доступны каталоги, в которых оно может сохранять данные, необходимые для работы: в /tmp хранятся временные данные, удаляемые между перезагрузками, а в /var более постоянные данные, такие как, например, кэши.

Чтобы не обращаться из приложения к SysFS напрямую, в Linux использовалась функция sysctl(), но в более новых версиях ОС эта функция удалена. На эту функцию чем-то похожа функция sysconf(), возвращающая некоторые параметры системы. В частности, вызвав ее, можно получить максимальное количество буферов, которое способна использовать функция sendmsg(). Эта функция обращается к кэшированным значениям, а не к операционной системе напрямую через системный вызов. Это делает ее довольно быстрой и удобной.

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

1. Что подразумевается под фразой «Все есть файл» в контексте Unix?

2. Почему в сетевых приложениях на C++ редко используется способ взаимодействия через специальные файлы?

3. Может ли веб-сервер заниматься настройкой системы и почему?

4. Что хранится в DevFS?

5. Почему сетевые устройства обычно не представлены в DevFS?

6. Какие файлы находятся в /proc/sys/?

7. Что может произойти в случае неправильной записи в файлы /proc? А в файлы /dev?

8. Где в /proc находятся сетевые настройки?

9. В каких случаях приложение может заниматься настройкой сетевого стека операционной системы?

10. Различаются ли параметры конфигурации одного и того же интерфейса для IPv4 и IPv6? Почему?

11. Для чего используется SysFS в Linux?

12. В чем разница между SysFS и ProcFS?

13. Где в /sys находятся настройки сетевых адаптеров?

14. Для чего нужны DebugFS и TraceFS? В чем их отличие?

15. Что такое BPFFS? Чем BPFFS может быть полезна разработчику сетевых приложений?

16. Для чего используется ConfigFS?

17. Для чего нужна SockFS?

18. Какие другие специальные файловые системы поддерживаются в Linux кроме перечисленных выше?

19. Какую информацию приложение может хранить в каталоге /tmp?

20. Какую информацию приложение может хранить в каталоге /var? Где именно в его иерархии?

21. Зачем нужна функция sysctl()?

22. Чем может быть полезна функция sysconf()?

23. Напишите программу, которой в качестве ключа сообщается название интерфейса и она только с помощью обращения к /proc получает и выводит MTU этого интерфейса.

Глава 14. Введение в сетевое программирование для ОС Windows

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

Билл Гейтс, «Why I Hate Spam», 2003

Введение

Читателю может показаться, что сетевые приложения в основном рассматриваются в контексте Unix-подобных ОС и серверов. Но это не так.

Сетевые приложения — это про взаимодействие, а во взаимодействии помимо серверов участвуют еще и клиенты. На данный момент преобладающей ОС в клиентском сегменте является Windows, и не замечать ее нельзя.

Цель этой главы — сформировать у читателя представление о составе Windows Network API, выделить наиболее актуальные элементы, которые мы более подробно рассмотрим в следующих главах, и дать рекомендации по использованию функций для эффективной работы.

Для начала мы рассмотрим структуру сетевого API операционной системы ОС Windows. Объем и функциональность этого интерфейса значительно больше сокетного POSIX API.

Начнем с обзора основных компонентов и архитектуры API, затем рассмотрим ключевые отличия от POSIX.

Также кратко разберем историю API и узнаем про устаревший API NetBIOS.

Затем познакомимся со стандартом WinSock 2, коснемся особенностей написания кода в ОС Windows: типов, констант, состава функций и библиотек, кодировок символов и т.п.

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

В конце главы мы также узнаем, какие расширения есть у WinSock и как лучше писать и не писать сетевые приложения Windows.

Внимание! Объем книги ограничен, и поэтому мы совершенно не коснемся работы сокетов в режиме ядра, в том числе WinSock Kernel. Объять необъятное не получится даже в нескольких книгах. Желающим придется разбираться с этой темой самостоятельно, читая профильную литературу или MSDN: https://learn.microsoft.com/en-us/windows-hardware/drivers/network/.

Можно порекомендовать также книгу Марка Руссиновича и др. «Внутреннее устройство Windows», 7-е изд. (изд-во «Питер»), в которой содержится отдельный раздел о сети, и книгу Павла Йосифовича «Работа с ядром Windows» (изд-во «Питер»).

Состав сетевого API Windows

ОС Windows имеет крайне обширный сетевой API, который постоянно растет и меняется. Некоторые разделы API устаревают, например разделы Dynamic Data Exchange — DDE — и NetBIOS, и на смену данным технологиям приходят другие.

Однако с целью обратной совместимости большая часть API еще долго сохраняется в более новых версиях ОС.

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

Раздел Networking and Internet

Этот раздел включает большую часть сетевого API, как низкоуровневого, такого как сокеты, так и более высокого уровня.

Посмотреть состав данного раздела API и его расширенное описание читатель может в Windows API Index62, в секции Networking and Internet, откуда по ссылкам можно изучить конкретные разделы.

Адресация

Расширенная функциональность для работы с адресами в ОС Windows имеет свою специфику, и набор функций для управления разрешением адресов различается для разных POSIX-систем.

Эти функции описаны в следующих разделах MSDN:

• Domain Name System, DNS63, — клиентские функции для разрешения имен через DNS и функции управления сервером DNS. Заголовочные файлы: windns.h.

• Multicast Address Dynamic Client Allocation Protocol, MADCAP64, — многоадресный протокол динамического распределения адресов клиентов, позволяющий клиентам запрашивать адреса многоадресной рассылки с серверов. Используя этот API, клиенты MADCAP могут получать, обновлять и освобождать многоадресные адреса. Заголовочные файлы: madcapcl.h.

Нас прежде всего интересует работа с DNS.

Сокеты

Сокетный API — это то, что мы в основном изучали до этого, и в ОС Windows он, конечно, тоже есть и описан в разделе MSDN Windows Sockets, или WinSock65. Описание дано как для POSIX-совместимого, так и для специфичного для Windows API сокета.

В API сокетов входит не только обмен данными, но и работа с сокетными провайдерами, то есть специальными системными приложениями, реализующими обмен. Заголовочные файлы, в которых описано большинство его функций и структур, — winsock2.h, winsock.h.

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

Альтернативные средства IPC

Помимо сокетов, ОС Windows предоставляет альтернативные способы межпроцессного обмена66. Прежде всего это:

• Почтовые ящики (Mailslots)67 — механизм для однонаправленного межпроцессного взаимодействия. Сообщения хранятся в почтовых ящиках. Сообщения обычно отправляются по сети либо на указанную машину, либо на все машины в указанном домене.

• Именованные каналы (Pipes)68 — примитив для межпроцессного обмена. Как правило, канал может быть анонимный либо именованный. Каналы обычно двунаправленные, хотя могут быть и однонаправленные. Для обмена данными обычно используют общую память, но обмен может производиться и по сети.

Основной заголовочный файл для этих примитивов: winbase.h. Их мы рассмотрим в соответствующей главе.

Служебный API

Для упрощения работы с некоторыми аспектами IP-стека, а также для настройки есть набор API и вспомогательных функций:

• IP Helper69 — API, позволяющий извлекать и изменять параметры конфигурации сети для локального узла. Заголовочные файлы: iphlpapi.h, icmpapi.h, iptypes.h, ntddk.h, netioapi.h и др. Часть заголовочных файлов относится к DDK — пакету для разработки драйверов.

• Management Information Base70 — API базы MIB, содержащей управляющую информацию, которую используют службы Remote Access Routing Services, вспомогательный протокол IP Helper, SNMP. Заголовочные файлы: lpmib.h, ifmib.h, tcpmib.h, udpmib.h, netioapi.h и прочие.

• Quality of Service, QoS71, — широкий набор QoS API для управления качеством обслуживания и передачей трафика. Заголовочные файлы: qosname.h, traffic.h, qos.h, qos2.h, qosobjs.h, qospol.h, qossp.h.

Работа в сети интернет

Чтобы работать через интернет по таким высокоуровневым протоколам, как HTTP, существует комплекс функций, реализованных в нескольких библиотеках, поставляемых вместе с ОС:

• Windows HTTP Services, WinHTTP72, — API HTTP-клиента для работы по HTTP. Заголовочные файлы: winhttp.h.

• HTTP Server73 — API HTTP-сервера. Заголовочные файлы: http.h.

• WebSocket Protocol Component74 — WebSocket API, который обеспечивает асинхронные двунаправленные каналы, используя протокол WebSocket поверх HTTP. Клиент использует HTTP для связи с сервером, а затем обе стороны переключаются на использование протокола более низкого уровня, например TCP или TLS. Заголовочные файлы: websocket.h.

• Windows Internet, WinINet75, — высокоуровневый интерфейс для работы по FTP, HTTP и другим стандартным интернет-протоколам. Заголовочные файлы: proofofpossessioncookieinfo.h, wininet.h, winineti.h.

• Windows RSS Platform76 — API для работы с каналами RSS, или Really Simple Syndication, каналами новостей из приложений. Это преимущественно JavaScript API, доступный в браузерах от Microsoft, таких как IE.

Межсетевой экран и фильтрация трафика

Для работы с трафиком, его фильтрации и более сложной обработки Windows предоставляет упомянутый ранее аналог BPF — платформу WFP.

Для нее существует API и еще несколько вспомогательных инструментов, обеспечивающих, например, доступ непосредственно к межсетевому экрану:

• Windows Filtering Platform, WFP77, — платформа фильтрации Windows предоставляет набор API и служб для создания приложений сетевой фильтрации. Заголовочные файлы: fwpmu.h.

• Windows Firewall with Advanced Security78 — API межсетевого экрана Windows, позволяющий совместно использовать подключения к интернету, защищать подключения с помощью межсетевого экрана и обеспечивать NAT. Ранее назывался Internet Connection Sharing и Internet Connection Firewall. Заголовочные файлы: netfw.h, networkisolation.h и прочие.

• Network Address Translation, NAT79, — API для NAT traversal дает приложениям возможность настраивать сопоставление портов на удаленных шлюзах, которое использует NAT. Заголовочные файлы: natupnp.h.

Удаленный вызов процедур, очереди, сериализация

Windows API предоставляет достаточно сложные и высокоуровневые системы обмена сообщениями и вызова процедур на удаленных серверах:

• Message Queuing, MSMQ80 — функции и COM-компоненты для работы с очередями сообщений. Заголовочные файлы: mq.h.

• Remote Procedure Call81 — API удаленного вызова процедур. Как серверная, так и клиентская часть. Заголовочные файлы: rpcndr.h, rpc.h, rpcasync.h, midles.h, rpcdce.h и прочие.

• Windows Web Services API, WWSAPI82, — реализация SOAP. Поддерживает набор протоколов, в том числе на .NET. Заголовочные файлы: webservices.h, icontentprefetchertasktrigger.h.

• XML HTTP Extended Request83 — API для выполнения XML-запросов по HTTP в несколько потоков. Использует обратные вызовы для получения уведомлений при обработке ответа. API предоставляет интерфейс IXMLHTTPRequest. Нечто похожее на браузерный интерфейс для AJAX. Заголовочные файлы: msxml6.h.

Мы рассмотрим некоторые концепции и протоколы в книге 3.

Управление сетью

Функции для управления сетью и SNMP:

• Network Management84 — функции управления сетью: учетными записями пользователей, сетевыми ресурсами. Заголовочные файлы: atacct.h, lmalert.h, lmat.h, lmaudit.h, lmconfig.h и прочие.

• Windows Networking, WNet85, — различные диалоги: добавление, удаление, изменение, запуск подключения и т.п. Заголовочные файлы: winnetwk.h.

• Simple Network Management Protocol, SNMP86, — SNMP используется для настройки удаленных устройств, мониторинга производительности сети, аудита, обнаружения сбоев или несанкционированного доступа. Заголовочные файлы: mgmtapi.h, snmp.h, winsnmp.h.

Подключения

API для работы с настройками подключений, DHCP и т.д. описаны в разделах:

• Get Connected Wizard87 — функции для работы с подключениями. Например, CreateVPNConnection() для создания VPN-подключения. Его функции не объявлены в заголовочных файлах SDK, поэтому требуется получать их адреса через GetProcAddress().

• Dynamic Host Configuration Protocol, DHCP88, — получение IP-адресов, адресов DNS-серверов, адреса роутера и прочих настроек соединения через DHCP. Заголовочные файлы: dhcpsapi.h, dhcpcsdk.h для клиента, dhcpssdk.h для сервера и dhcpv6csdk.h для поддержки IPv6.

• Network List Manager, NLM89, — позволяет приложениям получать список доступных сетевых подключений. Заголовочные файлы: netlistmgr.h и др.

Беспроводные сети

Набор функций для беспроводных сетей разделен по технологиям и компонентам:

• Bluetooth90 — сокеты и API, поддерживающие стек Bluetooth. Заголовочные файлы: bluetoothapis.h.

• IrDA91 — сокеты и стек для работы с инфракрасным портом. Заголовочные файлы: af_irda.h.

• Mobile Broadband92 — API мобильного широкополосного доступа. Используется для подключения к сотовым сетям. Заголовочные файлы: mbnapi.h и прочие.

• Native Wi-Fi93 — API компонента автоматической настройки беспроводных сетей. Хранит профили сетей как XML-документы. Заголовочные файлы: wlanapi.h.

• Windows Connect Now94 — позволяет мобильным, IoT-устройствам, точкам доступа 802.11, компьютерам подключаться и безопасно обмениваться настройками друг с другом. Заголовочные файлы: wcndevice.h.

• Windows Connection Manager95 — диспетчер соединений Windows, позволяющий создавать и настраивать сетевые подключения. Заголовочные файлы: wcmapi.h.

Телефония

Ранее были актуальны функции для работы с телефонией, модемами и факсами:

• Fax Service96 — набор функций для управления аппаратурой и службой факсов. Заголовочные файлы: faxdev.h, faxcom.h, winfax.h и прочие.

• Routing and Remote Access Service, RAS97, — API службы удаленного доступа, управляющий подключениями через модем и записями в телефонной книге. Раньше этот API использовался для программ-«звонилок», выполняющих дозвон через модем, чтобы затем установить интернет-подключение. Заголовочные файлы: ras.h, rasdlg.h, rasshost.h и прочие.

• Telephony Application Programming Interfaces, TAPI98, — API классической и IP-телефонии. C API, COM API и несколько провайдеров телефонии. Заголовочные файлы: tapi.h, tapi3.h и прочие.

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

Общие сетевые ресурсы

Набор API для работы с общими сетевыми ресурсами, распределенными и сетевыми ФС:

• Network Share Management99 — управление и мониторинг каналов Server Message Block. Заголовочные файлы: lmshare.h и прочие.

• SMB Management100 — управление SMB. Позволяют создавать общие ресурсы, удалять их, работать с файлами. Также содержит высокоуровневый интерфейс и оснастки WMI. Заголовочные файлы: smbmanagement.h, lm.h, lmcons.h и прочие.

• Web Distributed Authoring and Versioning, WebDAV101, — расширение HTTP для удаленной работы с файлами и каталогами. Заголовочные файлы: websocket.h.

• Distributed File System102 — API распределенной файловой системы. Заголовочные файлы: lmdfs.h.

• Peer-to-Peer103 — API одноранговой сети, появившийся в Windows Vista. Заголовочные файлы: p2p.h, peerdist.h, drt.h, pnrpdef.h, pnrpns.h.

• Windows Network Virtualization104 — виртуализация сети. Позволяет создавать сети виртуальных машин и переносить IP-адреса, а также целые топологии в облачные центры обработки данных. Заголовочные файлы: wnvapi.h.

Функции других разделов

В других, «несетевых», разделах тоже есть несколько относящихся к сети функций.

Диагностика и мониторинг

Мониторинговые и отладочные функции:

• Network Monitor105 — API сетевого монитора, кратко рассмотренного в главе 21. Заголовочные файлы: netmon.h и прочие.

• Network Diagnostics Framework, NDF106, — фреймворк для сетевой диагностики. Позволяет обрабатывать распространенные сетевые проблемы. Заголовочные файлы: ndfapi.h, ndhelper.h, ndattrib.h.

Сетевой монитор мы рассмотрим в главе 22.

Прочее

В ОС Windows реализовано множество других API для работы с различными подсистемами и протоколами:

• Network Access Protection, NAP107, — платформа, которая предоставляет набор компонентов для защищенного доступа к сетям. Ограничивает доступ клиента до тех пор, пока не будут выполнены требования политик безопасности. Заголовочные файлы: NapUtil.h, NapProtocol.h и прочие.

• Network Policy Server Extensions, NPS108, — серверные расширения политик сети для Network Policy Server. По сути, реализация RADIUS-сервера и прокси-сервера. Заголовочные файлы: authif.h, sdoias.h.

• Network Load Balancing, NLB109, — кластеры, балансирующие сетевую нагрузку между несколькими портами и серверами в соответствии с настраиваемым набором правил. Набор WMI-классов.

• Windows Messaging Application Programming Interface, MAPI110, — API для обмена сообщениями, основанный на COM. Обычно используется для взаимодействия с Microsoft Exchange Server, что дает возможность приложениям работать с e-mail.

• IIS API111, в том числе ISAPI112, — API для взаимодействия с веб-сервером Microsoft Internet Information Services.

• Remote Desktop Services113 — API для служб терминалов и удаленных рабочих столов: wtsapi32.h и т.п.

• Communication Resources114 — управление коммуникационными ресурсами, то есть устройствами, обеспечивающими двунаправленный поток данных. Сюда относятся модемы, факсы, последовательные и параллельные порты.

На этом сетевые возможности не заканчиваются, так как существует еще большое количество оберток, например MFC — классы, в которых поддерживаются сокеты, — а также подобные API. Также в ОС Windows существует подсистема WSL, сетевые особенности которой мы рассмотрим в главе 20.

В других ОС также есть функции для работы с большинством протоколов, которые поддерживает ОС Windows. Обычно эти функции реализованы в сторонних библиотеках. Но в случае Windows это компоненты от разработчика ОС, и хотя некоторые требуется устанавливать как отдельные пакеты, многие поставляются с дистрибутивом системы. Поэтому их можно считать частью ОС.

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

Модули Python

Для использования сокетов в Python необходимо импортировать модуль socket, который мы уже рассмотрели в предыдущих главах. Он кросс-платформенный и не дает возможности использовать функции, специфичные для WinSock.

Некоторая специфичная для ОС Windows функциональность все же доступна в модуле socket. Например, для Windows модуль предоставляет следующие дополнительные опции:

• SIO_RCVALL.

• SIO_KEEPALIVE_VALS.

• SIO_LOOPBACK_FAST_PATH.

• RCVALL_IPLEVEL.

• RCVALL_MAX.

• RCVALL_OFF.

• RCVALL_ON.

• RCVALL_SOCKETLEVELONLY.

Модуль не требует инициализации либо деинициализации сети через вызовы WSAStartup() и WSACleanup() — он вызывает их при загрузке и выгрузке автоматически.

Также существуют пакеты, которые позволяют использовать функциональность, специфичную для конкретной ОС.

Для Windows это сторонний пакет pywin32115, который предоставляет доступ к большому количеству функций WinAPI.

Он содержит модули, в которых реализован доступ к сетевому API:

• win32file116 — почтовые ящики и функция DeviceIoControl(). Этот же модуль предоставляет функции TransmitFile(), ConnectEx(), AcceptEx(), GetAcceptExSockaddrs(), о которых мы расскажем в книге 2, а также стандартные функции WinSock2:

• WSAEventSelect().

• WSAEnumNetworkEvents().

• WSAAsyncSelect().

• WSASend().

• WSARecv().

• win32pipe117 — каналы, именованные либо анонимные.

• win32wnet118 — сетевой API ОС Windows: WNetOpenEnum(), WNetGetConnection(), NetBIOS API и др.

• win32ras119 — RAS API.

• win32net120 — API для работы с сетевыми группами и общими ресурсами: NetGroupAdd(), NetShareEnum() и т.п.

• win32inet121 — высокоуровневый Windows Internet API, то есть функция InternetOpen(), а также сопряженные.

• internet122 — интернет-интерфейсы ActiveX.

• mapi123 — COM-интерфейс для MAPI.

• isapi124 — поддержка ISAPI.

• win32ts125 — интерфейс к API служб терминалов.

• exchange126 — COM-интерфейс для API Microsoft Exchange.

Особенностью пакета pywin32 является то, что предоставляемые им функции почти в точности повторяют WinAPI.

Модуль win32file содержит несколько функций для управления коммуникационными ресурсами. Также модуль предоставляет еще несколько полезных для сети функций:

def CalculateSocketEndPointSize(socket)

def GetAcceptExSockaddrs(sAccepting, buffer) -> \

tuple[iFamily, LocalSockAddr, RemoteSockAddr]

Функция CalculateSocketEndPointSize() вычисляет, сколько байтов необходимо для буфера сокета, а функция GetAcceptExSockaddrs() разбирает конечные точки соединения из буфера, переданного в функцию AcceptEx().

Некоторый API реализуется и в других пакетах. Например, своя реализация именованных каналов есть в асинхронной библиотеке Trio127. Ее мы подробнее рассмотрим в книге 2.

Но эти пакеты не содержат модулей для вызова API WinSock. Кроме того, состав функций, которые предоставляют любые пакеты, зависит от желания их авторов.

Чтобы использовать произвольный API, который предоставляет ОС, в Стандартной библиотеке Python существует модуль ctypes128. С помощью этого модуля можно загрузить динамическую библиотеку, экспортировать из нее функции, а также создавать требуемые C-вызовами структуры и реализовывать функции обратного вызова на основе функций Python.

Хотя ctypes позволяет загружать любые библиотеки, он предоставляет уже готовый набор основных предварительно загруженных библиотек для ОС Windows, таких как kernel32.dll и даже iphlpapi.dll, на примере которого в главе 19 мы и рассмотрим, как можно использовать этот модуль.

В дополнение к стандартным типам C для работы с функциями WinAPI уже определен набор основных типов, используемых в его функциях: DWORD, USHORT, HANDLE и прочих.

Внимание! В Python используются значения из POSIX-совместимых систем, например из Linux. И в случае ОС Windows значения констант могут отличаться. Поэтому их необходимо применять с осторожностью и проверять значение перед использованием.

Поверх pywin32 и ctypes реализован сторонний пакет winsys129 — набор инструментов на Python для решения административных задач. Он будет полезен разработчику под ОС Windows.

Можно использовать предоставляемые инструменты, подключать их как Python-модули и переиспользовать их код в своих проектах.

В частности, в winsys есть библиотека IPC130, в которой реализованы каналы и почтовые ящики.

NetBIOS — предшественник сокетов

NetBIOS, или Network Basic Input/Output System, являлся первой реализацией сети в ОС Windows. Он был разработан фирмой Sytek Inc. в 1983 году.

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

История NetBIOS

NetBIOS поддерживал возможность работы в локальных сетях из узлов IBM/PC поверх одной из первых технологий локальных сетей — IBM PC Network.

Впоследствии был создан NetBIOS Enhanced User Interface, или NetBEUI, — эмулятор, позволяющий NetBIOS работать в сетях Token Ring.

Сетевой и транспортный уровень был реализован в протоколе NBF, — NetBIOS Frames. Этот протокол не является маршрутизируемым, то есть создавать соединенные сети он не позволяет.

Одну из старых версий NBF в Microsoft также назвали NetBEUI, что вносит дополнительную путаницу.

В годы расцвета Novel Netware был реализован NBX — NetBIOS over IPX/SPX. В 1987-м NetBIOS стал работать поверх Ethernet, используя канальный протокол IEEE 802.2.

Тогда же появился новый протокол NBT — NetBIOS over TCP/IP. Протокол описан в RFC 1001 «Protocol standard for a NetBIOS service on a TCP/UDP transport: Concepts and methods» и RFC 1002 «Protocol standard for a NetBIOS service on a TCP/UDP transport: Detailed specifications».

NetBIOS давно уже не поддерживается в ОС Windows — его поддержка была удалена еще в Windows Vista и Windows Server 2008.

В состав NetBIOS входят:

• Протокол сеансового уровня.

• Транспортный протокол NBT, работающий поверх TCP и UDP.

• Набор служб:

• NetBIOS-NS. Служба имен. При использовании NBT данную службу реализует сервер WINS — Windows Internet Name Service. Он позволяет регистрировать и освобождать имена. WINS необходим для определения корректных IP-адресов. Он использует порты 137 TCP и UDP.

• NetBIOS-DGM. Обмен дейтаграммами для связи без установления соединения. Использует UDP-порт 138.

• NetBIOS-SSN. Служба сеансов для связи с установлением соединения. TCP-порт 139.

• Инструменты диагностики, такие как программа nbtstat, а также группа параметров NetBIOS команды ipconfig.

Если не считать того, что NetBIOS устарел, NBT остался единственным транспортным протоколом, поверх которого работают сервисы NetBIOS. Стек протоколов NetBIOS изображен на рис. 14.1: NBF и IPX — устаревшие компоненты.

Рис. 14.1. Состав NetBIOS

Когда NetBIOS работает через NBT, узлу назначается тип, определяемый тем, как узел работает с IP-адресами:

• B — широковещательные узлы. Устанавливают связь с абонентом, используя широковещательную рассылку.

• P — узлы «точка-точка». Поиск узлов по именам работает через WINS.

• M — узлы смешанного типа. Сначала используют широковещательную рассылку, затем WINS.

• H — гибридные узлы. Сначала используют WINS, затем широковещательную рассылку.

Используемый тип узла отображается при открытии командной строки и вводе ipconfig /all.

WINS является аналогом DNS для NetBIOS, даже формат его пакетов идентичен DNS. Но он не поддерживает иерархическую структуру. И хотя WINS пока еще не объявлен устаревшим, Microsoft рекомендует использовать DNS, а не устанавливать WINS в новых системах.

В NetBIOS каждый участник должен зарегистрироваться в сети, используя службу имен. Для регистрации требуется уникальное имя длиной не более 15 символов.

Типы служб в NetBIOS

Длина имени службы или группы служб в NetBIOS — 16 символов.

Последний символ — это суффикс, который обозначает тип службы, например:

• 00 — служба рабочей станции, возвращающая имя рабочей станции.

• 03 — служба обмена сообщениями Windows.

• 06 — служба удаленного доступа.

• 20 — файловая служба.

• 21 — клиент службы удаленного доступа.

Или тип группы:

• 00 — службы рабочей станции: рабочая группа или имя домена.

• 1С — контроллеры домена.

Для установления связи и передачи данных приложениям необходимо знать адреса ресурсов.

Самый простой вариант — использовать службу дейтаграмм, которая позволяет отправлять сообщения группе узлов. Однако для передачи больших объемов данных в NetBIOS используется служба сеансов, которая работает поверх TCP.

В случае NetBIOS поверх TCP IP-адрес узла с определенным именем может быть определен через WINS или путем широковещательной отправки пакета Name Query.

Сеанс начинается с запроса, указания IP-адреса и определения TCP-порта удаленного объекта. После обмена сообщениями приложения закрывают сессию. Помимо обеспечения связи, NetBIOS предоставляет возможности управления сетевыми адаптерами и их мониторинга.

Пример API NetBIOS

API NetBIOS был относительно прост и представлен одной структурой и одной функцией:

#include <nb30.h>

typedef struct _NCB

{

// Команда NetBIOS.

UCHAR ncb_command;

// Код возврата операции.

UCHAR ncb_retcode;

// Локальный номер сеанса.

UCHAR ncb_lsn;

// Номер имени локальной сети.

UCHAR ncb_num;

// Буфер с данными.

PUCHAR ncb_buffer;

// Размер буфера.

WORD ncb_length;

// Имя удаленного приложения.

UCHAR ncb_callname[NCBNAMSZ];

// Имя локального приложения.

UCHAR ncb_name[NCBNAMSZ];

// Тайм-аут приема данных.

UCHAR ncb_rto;

// Тайм-аут отправки данных.

UCHAR ncb_sto;

// Адрес подпрограммы для вызова после завершения асинхронной команды.

void()(_NCB *) *ncb_post;

// Номер сетевого адаптера для выполняемой команды.

// LANA — Local Area Network Adapter.

UCHAR ncb_lana_num;

// Код возврата операции.

UCHAR ncb_cmd_cplt;

// Всегда 0.

#if _WIN64

UCHAR ncb_reserve[18];

#else

UCHAR ncb_reserve[10];

#endif

// Дескриптор события, активируемого при завершении команды.

HANDLE ncb_event;

} NCB, *PNCB;

UCHAR Netbios(PNCB pncb);

Структура NCB — блок управления сетью, или Network Control Block.

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

Сначала сохранить все номера локальных сетей:

// Структура, которая содержит локальные сети.

LANA_ENUM l_enum;

NCB ncb;

ncb.ncb_command = NCBENUM;

ncb.ncb_buffer = reinterpret_cast<PUCHAR>(&l_enum);

ncb.ncb_length = sizeof(LANA_ENUM);

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

if (NRC_GOODRET != Netbios(&ncb))

{

return ncb.ncb_retcode;

}

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

std::cout << "LANA count = " << +l_enum.length << std::endl;

std::array<UCHAR, 1024> data_buffer;

// Имя узла.

constexpr char local_name[] = "corp.node_name.com";

for (int i = 0; i < l_enum.length; ++i)

{

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

std::fill_n(reinterpret_cast<char*>(&ncb), sizeof(NCB), 0);

// Команда получения статистики.

ncb.ncb_command = NCBASTAT;

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

ncb.ncb_lana_num = l_enum.lana[i];

ncb.ncb_buffer = data_buffer.data();

ncb.ncb_length = data_buffer.size();

std::copy(local_name.c_str(), local_name.c_str() + local_name.size(),

ncb.ncb_callname);

Netbios(&ncb);

if (NRC_GOODRET != ncb.ncb_retcode) std::cerr << "Error" << std::endl;

}

Команд NetBIOS достаточно много: команды вызова RPC, отправки и получения данных, установки соединения, опроса аппаратуры и т.п.

Итак, мы кратко рассмотрели, что такое подсистема NetBIOS. Для базового понимания приведенной информации должно быть достаточно. На практике современному разработчику она вряд ли потребуется.

Введение в WinSock

Windows Sockets — это API сокетов в ОС Windows, который поддерживает обмен данными между приложениями с использованием различных сетевых протоколов, таких как TCP/IP или устаревшего IPX. Высокоуровневая структура его реализации изображена на рис. 14.2.

Актуальная версия — Windows Sockets 2.2 API, а его краткое название — WinSock.

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

Рис. 14.2. Структура реализации API Windows Sockets

Часть WinSock API реализована так, чтобы обеспечить совместимость с BSD-сокетами, но часть API специфична для ОС Windows.

История сокетов в Windows

Ранние операционные системы Microsoft Windows имели ограничения по работе с сетью, которые были связаны с использованием подсистемы NetBIOS. В Microsoft в то время не считали необходимым поддерживать работу с TCP/IP.

Для работы с TCP/IP в MS-DOS свои решения предоставляли университетские группы, в том числе из MIT, и коммерческие фирмы, такие как FTP Software, Sun Microsystems, Ungermann-Bass и Excelan, причем иногда как часть конкретных программно-аппаратных комплексов.

После выпуска Microsoft Windows 2.0 к ним присоединились и другие, такие как Distinct и NetManage. Они помогли реализовать TCP/IP-стек для Windows. Каждая из реализаций использовала свой API. Без единого стандартного интерфейса независимым прикладным разработчикам приходилось трудно. Стало понятно, что требуется стандартизация.

Модель сокетного API была разработана рабочей группой Birds of a Feather и согласована на BBS-сети CompuServe в октябре 1991 года.

Первое издание спецификации написали Мартин Холл и Марк Tовфик из Microdyne, Джефф Арнольд из Sun Microsystems, Генри Сандерс и Дж. Алард из Microsoft при участии многих других разработчиков.

Сокетный API не заменил NetBIOS, однако постепенно, с появлением новых сервисов на основе WinSock и расширения возможностей API, NetBIOS стал отмирать, пока не устарел окончательно и не был убран из поставки ОС.

WinSock описан достаточно формальной спецификацией, и даже Microsoft не расширяет его бесконтрольно.

Инициализация WinSock

Семейства Windows 9x, 3.x и более ранние создавались как системы для одного пользователя. В них была реализована ограниченная сетевая функциональность либо поддержка сети вообще не включалась в дистрибутив и требовала установки отдельных драйверов и библиотек.

Каждое приложение могло выбирать, инициализировать ли ему сетевую подсистему и загружать ли необходимые WinSock библиотеки.

«Сетевые» операционные системы линейки Windows NT были ориентированы на многопользовательскую работу и позже стали иметь в составе полноценный сетевой стек по умолчанию. Но требование инициализации библиотеки в каждом приложении сохранилось.

В современных версиях ОС Windows вызов инициализации нужен также для того, чтобы старые приложения работали с той версией сетевой подсистемы, для которой они создавались и с которой проверялась их работа.

Инициализация библиотеки WinSock выполняется вызовом функции WSAStartup():

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

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

• wVersionRequested — требуемая версия библиотеки, которую нужно загрузить. Старший байт указывает младшую версию запрошенной библиотеки WinSock, а младший байт — основную версию.

• lpWSAData — указатель на структуру WSADATA, которую WSAStartup() заполняет информацией, относящейся к версии загружаемой библиотеки.

Аббревиатура WSA означает Windows Sockets Application.

Не все сетевые функции начинаются с данной аббревиатуры, например: GetExtendedUdpTable(), HttpSendRequestW(), FwpmProviderAdd0() и подобные.

WSA говорит о том, что данная функция относится именно к сокетному API для приложений.

Чтобы сформировать версию, обычно используется макрос MAKEWORD(x, y), в котором x — старший байт, а y — младший байт. На момент написания книги версия 2.2 является актуальной, а версии 1.x — устаревшими.

Внимание! Порядок байтов версии обратный: сначала — младший, потом — старший.

В случае успеха функция вернет 0. Если же не удается загрузить библиотеку WinSock, функция возвращает ошибку:

• WSASYSNOTREADY — сетевая подсистема не готова к сетевому взаимодействию.

• WSAVERNOTSUPPORTED — запрошенная версия WinSock не предоставляется реализацией.

• WSAEINPROGRESS — выполняется блокирующая операция сокетов Windows 1.1.

• WSAEPROCLIM — достигнуто ограничение на количество задач, поддерживаемых реализацией сокетов.

• WSAEFAULT — параметр lpWSAData не является допустимым указателем.

Расширенный код ошибки можно получить, используя функцию WSAGetLastError().

Кроме инициализации, функция WSAStartup() выполняет согласование. Вызывающая сторона передает в wVersionRequested максимальную версию спецификации Windows Sockets, которую она поддерживает.

Библиотека WinSock DLL указывает в своем ответе максимальную версию спецификации Windows Sockets, которую она может поддерживать. WinSock DLL также отвечает версией спецификации Windows Sockets, которую, как ожидается, будет использовать вызывающая сторона.

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

Внимание! Без инициализации сетевой подсистемы большинство сетевых функций будет возвращать ошибку SOCKET_ERROR. Код ошибки, получаемый через вызов WSAGetLastError(), будет установлен в WSANOTINITIALISED.

Большая часть современного WinSock API реализована в библиотеке ws2_32 dll.

Она поддерживает спецификации WinSock:

• 1.0;

• 1.1;

• 2.0;

• 2.1;

• 2.2.

Версию 2.2 поддерживает большинство ОС Windows, в том числе NT 4.0 и Windows 95 с последними патчами. А версию 1.1 можно найти разве что на Windows NT 3.5, оригинальной Windows 95 и Windows CE.

В классе библиотеки-обертки SocketWrapper вы также могли видеть метод initialize(), вызываемый из конструктора. В Unix-подобных системах этот метод пустой, так как сеть инициализируется во время старта операционной системы.

В ОС Windows этот метод вызывает функцию WSAStartup():

void initialize()

{

WSADATA wsaData;

// Инициализировать Winsock.

if (auto result = WSAStartup(MAKEWORD(2, 2), &wsaData); result != 0)

{

// WSAStartup() вернет код ошибки.

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

"WSAStartup()");

}

// Если инициализация прошла, иногда можно проверить версию.

/*

if (LOBYTE(wsaData.wVersion) != 2 ||

HIBYTE(wsaData.wVersion) != 2)

{

...

}

*/

}

Методу передается указатель на структуру WSAData, в которую сокетный провайдер запишет некоторые свои параметры.

Кратко рассмотрим эту структуру:

typedef struct WSAData

{

// Запрошенная версия.

WORD wVersion;

// Старший байт поддерживаемой версии.

WORD wHighVersion;

// Нуль-завершенная ASCII-строка, в которую Ws2_32.dll

// скопирует описание реализации сокетов Windows до 256 символов длиной.

char szDescription[WSADESCRIPTION_LEN + 1];

// Нуль-завершенная ASCII-строка, в которую Ws2_32.dll

// скопирует информацию о состоянии или конфигурации.

char szSystemStatus[WSASYS_STATUS_LEN + 1];

// Максимальное количество сокетов, которые могут быть открыты. Устарел.

unsigned short iMaxSockets;

// Максимальный размер сообщения дейтаграммы.

// Атрибут игнорируется для сокетов Windows версии 2 и более поздних.

unsigned short iMaxUdpDg;

// Указатель на информацию о провайдере.

// Атрибут нужно игнорировать для WinSock, начиная с версии 2.

char FAR * lpVendorInfo;

} WSADATA, *LPWSADATA;

Внимание! Поля iMaxSockets, iMaxUdpDg и lpVendorInfo могут идти перед szDescription, то есть их порядок зависит от версии библиотеки сокетов и на этот порядок нельзя полагаться в приложениях.

Атрибуты iMaxSockets, iMaxUdpDg и lpVendorInfo сохранены для совместимости со спецификацией Windows Sockets 1.1. Их не следует использовать при разработке новых приложений.

LPWSADATA означает Long Pointer to WSADATA. Это «венгерская нотация», которая была принята в Microsoft при разработке Word, Excel и других приложений, а затем и в Microsoft Windows, в том числе для Windows API.

Когда приложение завершает работу с интерфейсом WinSock, следует вызвать функцию WSACleanup():

int WSACleanup();

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

Возвращаемые значения:

• WSANOTINITIALISED — сетевая подсистема не была инициализирована вызовом WSAStartup().

• WSAENETDOWN — сетевая подсистема вышла из строя.

• WSAEINPROGRESS — выполняется блокирующий вызов Windows Sockets 1.1, или провайдер обрабатывает функцию обратного вызова.

В обертке эту функцию вызывает метод deinitialize():

void deinitialize()

{

WSACleanup();

}

Внимание! Если в приложении было несколько вызовов WSAStartup(), функция WSACleanup() должна быть вызвана для каждого.

Требуется ли проверять значения, возвращаемые WSACleanup()? Как правило, этого не делают, однако такая практика порочна.

Необходимо проверять всё и всегда.

В случае, когда функция завершится неудачно, это поможет найти ошибку в коде.

Важно понимать, что делает, а чего не делает функция деинициализации. Помимо отмены вызовов, она закроет сокеты, так же как closesocket(), и удалит их. Но если какой-то протокол выполнил изменения в сети, эти изменения не будут отменены. Например, не будет отменена выполненная регистрация узлов PNRP и не будут удалены оформленные подписки.

В версии WinSock 1.1 и ранее перед вызовом WSACleanup() необходимо было вызвать функцию WSACancelBlockingCall(), чтобы отменить блокирующие вызовы:

int WSAAPI WSACancelBlockingCall();

В версии WinSock 2.2.0 данная функция была удалена.

Основные заголовочные файлы и DLL для Windows

Как и в Unix-подобных системах, в ОС Windows существует набор заголовочных файлов, но он значительно отличается.

Заголовочные файлы Windows Sockets следующие:

• af_irda.h — семейство адресов для инфракрасных трансиверов. Структура адреса SOCKADDR_IRDA.

• in6addr.h — семейство адресов IPv6. Структура адреса IN6_ADDR.

• mstcpip.h — структуры и перечисления для TCP.

• mswsock.h — расширения Microsoft к спецификации WinSock. Дополнительные функции, например AcceptEx(), TransmitFile(), WSARecvEx(), GetAcceptExSockaddrs(), функции для работы с зарегистрированными буферами.

• mswsockdef.h — структура RIOBUF. Описывает порцию данных зарегистрированного буфера.

• nsemail.h — структуры и перечисления для провайдера электронной почты.

• nspapi.h — функции для работы с протоколами и службами.

• socketapi.h — содержит только одну функцию SetSocketMediaStreamingMode() и, вероятно, нужен для работы с QoS.

• sporder.h — функции, управляющие порядком провайдеров пространств имен в Windows Sockets.

• transportsettingcommon.h — структуры для ioctl SIO_QUERY_TRANSPORT_SETTING.

• winsock.h и winsock2.h — все основные функции сокетов, включая инициализацию и DNS. Файл winsock.h относится к более старой версии и используется с winsock.dll. А в файле winsock2.h содержатся функции WinSock 2.x.

• ws2atm.h — структуры для работы с сокетами Asynchronous Transfer Mode.

• ws2spi.h — интерфейс провайдеров транспортов и пространств имен.

• ws2tcpip.h — некоторые функции, относящиеся к TCP/IP, функции для работы с адресами, порядком байтов и некоторыми параметрами UDP.

• wsipv6ok.h — содержит макросы, определяющие совместимость различных функций с IPv6.

• wsipx.h — некоторые функции, относящиеся к IPX.

• wsnetbs.h — функциональность для работы NetBIOS.

• wsnwlink.h — функциональность и структуры для работы с IPX/SPX.

• wspiapi.h — основной заголовочный файл, требуется объявлять перед ws2tcpip.h.

• wsrm.h — функционал для работы с надежной многоадресной передачей.

Полный список заголовков можно посмотреть в разделе «Networking and Internet» MSDN131. Некоторые из них мы будем упоминать по ходу изложения.

Обычно при работе с сокетами в Windows включают заголовочный файл winsock2.h.

Некоторые заголовочные файлы никогда не должны включаться явно. Например, структура WSABUF, используемая в функциях отправки и приема данных, определена в заголовочном файле ws2def.h. Но вместо данного файла необходимо включать файл winsock2.h, в котором объявлены функции, использующие данную структуру, и уже включен нужный заголовочный файл ws2def.h.

ANSI- и UNICODE-версии функций

Некоторые функции Windows API имеют несколько имен, оканчивающихся на A, W или вообще не имеющих суффикса.

На A заканчиваются функции, которые работают с ANSI-символами. Функции, заканчивающиеся на W, используются для работы с широкими символами типа wchar_t.

ASCII — 7-битная таблица символов, содержащая символы от 0 до 127.

ANSI — 8-битный наборов символов, в котором первая часть таблицы сохраняется неизменной.

Вторая часть таблицы в ANSI — это национальные символы и типографика. Реальные образы символов второй части таблицы определяются кодовой страницей, или кодировкой.

В американских и западноевропейских версиях ОС Windows это обычно CP-1252, которая содержит большинство нелатинских символов для различных европейских алфавитов.

В России — CP-1251, содержащая кириллические символы. Пример кодовых таблиц показан на рис. 14.3.

Рис. 14.3. Кодовые таблицы ASCII и ANSI

Функции без суффикса — это обычно макросы, которые раскрываются в то или иное значение имени, в зависимости от того, используется ли при компиляции Unicode и определен ли макрос UNICODE.

Рассмотрим отличие на примере функций преобразования адресов, которые подробно будут описаны далее. Эти функции объявлены в WS2tcpip.h таким образом:

#include <WS2tcpip.h>

int WSAAPI InetPtonW(

int family,

// "Широкая" строка.

PCWSTR pszAddrString,

PVOID pAddrBuf

);

PCWSTR WSAAPI InetNtopW(

int family,

const void *pAddr,

// "Широкая" строка.

PWSTR pStringBuf,

size_t stringBufSize

);

// Псевдонимы для ASCII-строк.

#define InetPtonA inet_pton

#define InetNtopA inet_ntop

// Выбор значения макроса, именующего функции.

#ifdef UNICODE

#define InetPton InetPtonW

#define InetNtop InetNtopW

#else

#define InetPton InetPtonA

#define InetNtop InetNtopA

#endif

Иными словами, функции InetNtopA() и InetPtonA() — это просто другие названия функций inet_ntop() и inet_pton(), которые работают с ANSI-символами.

Но функции InetNtopW() и InetPtonW() используются для работы с широкими символами. Им необходимо передавать буфер типа wchar_t. Если разработчик использует макросы InetNtop() и InetPton(), то есть хочет поддержать оба варианта, он должен это учитывать.

Для большинства функций Windows API макрос UNICODE определяет порядок работы с текстом.

В Windows есть макрос TEXT(), который на этапе компиляции позволяет вставить необходимый строковый тип в зависимости от значения макроса UNICODE.

Этот макрос определен в заголовочном файле winnt.h.

Полностью аналогичен ему макрос _T(), который определён в заголовочном файле tchar.h. Использовать можно любой из них.

Внимание! Используйте макрос TEXT() всегда, когда передаете строковый буфер WinSock функциям, которые начинаются с префикса, например WSA, и не содержат явного указания на используемый тип символов в суффиксе.

Вместо типов char или wchar_t используйте тип TCHAR. В зависимости от макроса UNICODE он становится либо WCHAR, либо char. Строки, которые могут работать, как в ANSI-, так и в UNICODE-вариантах компиляции, имеют тип LPTSTR.

Вызывать функции нужно так:

uint32_t ip;

// ANSI-версия:

auto res = InetPtonA(AF_INET, "192.168.1.1", &ip);

// UNICODE-версия:

auto res = InetPtonW(AF_INET, L"192.168.1.1", &ip);

// Общий случай:

auto res = InetPton(AF_INET, TEXT("192.168.1.1"), &ip);

Внимание! POSIX-совместимые функции, такие как inet_ntop() и inet_pton(), всегда используют тип char, поэтому вызываются как обычно.

Далее мы не будем указывать отдельно функции A и W, предполагая следующее:

• POSIX-вариант работает всегда с ANSI-буферами.

• Вместо WinAPI-функций всегда используются макросы без суффикса.

В современных версиях ОС Windows компания Microsoft рекомендует использовать только Unicode-функции, чтобы избежать проблем с кодировками. Но для кросс-платформенных приложений это не всегда выполнимо, поэтому часто желательно пользоваться только ANSI-версиями. И даже только для Windows-приложений следование рекомендации Microsoft снижает их переносимость между разными версиями ОС Windows.

Подключение библиотек

Библиотека WinSock не добавляется к приложению автоматически. Обычно приложения связывают с библиотекой ws2_32.lib, которую требуется подключить явно.

Один из вариантов — использование директивы pragma:

#pragma comment(lib, "Ws2_32.lib")

Однако этот способ не очень хорош, так как засоряет код директивами, специфичными для компоновщика Microsoft.

Внимание! Не подключайте библиотеки через pragma.

Лучше оставить компоновку на усмотрение системы сборки, например CMake:

add_library("${PROJECT_NAME}" ${${PROJECT_NAME}_SRC})

...

if (WIN32)

# Подключить библиотеки Winsock.

target_link_libraries("${PROJECT_NAME}" PUBLIC wsock32 ws2_32)

endif()

Можно также добавить библиотеку wsock32, а большинство остальных библиотек компонуются неявно.

Более полный набор DLL, в которых реализован API сокетов:

• ws2_32.dll — большинство функций для сокетов из Winsock API.

• mswsock.dll — функции для работы с файлами и WSAIoctl().

• msafd.dll — service provider, провайдер транспорта для работы сетевых протоколов, таких как TCP/IP, UDP/IP и т.д.

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

Для Python проблем с компоновкой нет, в Python-приложении достаточно импортировать модуль socket. Проблемы могут возникнуть лишь при использовании нестандартных функций, которые не входят в модуль socket и не являются методами класса socket.socket. В этом случае библиотеки загружаются динамически, например, при помощи стороннего пакета PyWin32:

import win32api

def GetDnsHostName():

Win32_ComputerNameDnsHostname = 1

try:

return win32api.GetComputerNameEx(Win32_ComputerNameDnsHostname)

except win32api.error as details:

# Обработать ошибку.

return ''

либо через динамическую загрузку библиотек, как показано в главе 10, в разделе о перечислении интерфейсов, а также в главе 20.

Расширения WinSock

Нестандартные функции, не имеющие отношения к спецификации WinSock, реализуются в отдельных библиотеках, у которых свои заголовочные файлы. Например, в Linux есть функция accept4(), а у Microsoft — своя функция AcceptEx().

Существуют расширения WinSock 2 от разных компаний, таких как Intel, Broadcom и Realtek, которые предоставляют дополнительные возможности для работы с сетевым оборудованием. Некоторые из этих расширений включают в себя поддержку различных аппаратных платформ, дополнительных протоколов, улучшенные функции безопасности и прочее.

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

О провайдерах будет рассказано далее, в главе 16.

Основан этот механизм на команде SIO_GET_EXTENSION_FUNCTION_POINTER, вызываемой функцией WSAIoctl().

Вызов обрабатывается провайдером, который возвращает указатель на функцию по ее GUID. Затем приложение может вызвать функцию напрямую, минуя библиотеку ws2_32.dll.

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

Пример загрузки расширения:

// GUID функции TransmitPackets().

// Доступны также WSAID_ACCEPTEX, WSAID_CONNECTEX, WSAID_POLL и прочие.

GUID transmit_packets_guid = WSAID_TRANSMITPACKETS;

LPFN_TRANSMITPACKETS TransmitPackets = nullptr;

DWORD dwBytes;

if (SOCKET_ERROR == WSAIoctl(sock, SIO_GET_EXTENSION_FUNCTION_POINTER,

// Входной буфер содержит идентификатор

// функции расширения.

static_cast<void*>(&transmit_packets_guid),

// Размер входного буфера с GUID.

sizeof(transmit_packets_guid),

// Сюда будет сохранен указатель на функцию.

&TransmitPackets,

// Размер выходного буфера с указателем.

sizeof(TransmitPackets),

&dwBytes, nullptr, nullptr))

{

// Ошибка.

...

}

// Здесь указатель TransmitPackets можно использовать как обычную функцию.

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

Стандарт менять долго, но Microsoft требуется реализовывать новую функциональность, поэтому они вынесли свои расширения в отдельную библиотеку, которую включили в состав ОС. Поэтому в ОС Windows, начиная с версии Vista, расширения от Microsoft, такие как WSAPoll(), WSASendMsg(), TransmitPackets() и подобные, экспортируются непосредственно из библиотеки WinSock и вызов функции WSAIoctl() для их загрузки не требуется. Но если требуется использовать расширения сторонних провайдеров, описанный механизм все еще актуален.

Нерекомендованные функции

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

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

Те же причины служат основаниями для объявления функции не рекомендованной к использованию и в Unix-подобных системах.

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

Предупреждение C4996 компилятор Microsoft Visual Studio выводит, если символ является устаревшим, то есть явно помечен модификатором __declspec(deprecated) или атрибутом, [[deprecated]] введенным в C++14.

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

#pragma warning(disable: 4996)

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

#define _WINSOCK_DEPRECATED_NO_WARNINGS

// Включение заголовочных файлов WinSock.

#include <winsock2.h>

...

Внимание! Макрос должен быть определен перед включением заголовочных файлов.

Откуда берутся примеры кода с нерекомендованными функциями

В разном коде, в том числе доступном в интернете, нерекомендованные функции могут использоваться очень широко. Особенно в примерах к статьям для начинающих. Авторы, которые их пишут, имеют разную квалификацию и сами часто не являются профессиональными разработчиками. Они переписывают эти примеры друг у друга либо из документации, которая не всегда актуальна и местами противоречива. А чтобы разбираться в теме, документацию надо читать внимательно и в большом объеме.

Но авторы примеров вполне могут позволить себе небрежное отношение, руководствуясь принципом «работает — и ладно». Они не обязаны обеспечивать надежность и работоспособность своего кода. Их проекты не проходят код-ревью, и им не нужно отвечать требованиям политик безопасной разработки. Поэтому на такие примеры ориентироваться не стоит, поскольку на практике в ИТ-компаниях вам, скорее всего, потребуется забыть то, что в них написано, открыть документацию и учиться пользоваться другим API.

Учитесь программировать так, как рекомендовано в «наборах лучших практик», — это менее затратно.

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

Макросы, предоставляемые WinAPI, и практики написания кода в ОС Windows

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

Вот пример макросов для работы с ошибками из заголовочного файла winerror.h:

//

// Значения кодов ошибок.

//

#define SEVERITY_SUCCESS  0

#define SEVERITY_ERROR    1

//

// Общая проверка на успех вызова. Неотрицательные числа — успех операции.

//

#define SUCCEEDED(hr) (((HRESULT)(hr)) >= 0)

//

// Инверсия проверки на успех.

//

#define FAILED(hr) (((HRESULT)(hr)) < 0)

//

// Проверка на ошибку.

//

#define IS_ERROR(Status) (((unsigned long)(Status)) >> 31 == SEVERITY_ERROR)

Для работы с COM API, возвращающим HRESULT, может применяться свой набор макросов:

//

// Вернуть код ошибки из HRESULT.

//

#define HRESULT_CODE(hr)  ((hr) & 0xFFFF)

#define SCODE_CODE(sc)    ((sc) & 0xFFFF)

//

// Вернуть из HRESULT объект, указывающий API или фреймворк, — причину ошибки.

//

#define HRESULT_FACILITY(hr)  (((hr) >> 16) & 0x1fff)

#define SCODE_FACILITY(sc)    (((sc) >> 16) & 0x1fff)

//

//  Вернуть флаг наличия ошибки.

//

#define HRESULT_SEVERITY(hr)  (((hr) >> 31) & 0x1)

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

// и анализировать.

#define SCODE_SEVERITY(sc)    (((sc) >> 31) & 0x1)

Для получения поля структуры по адресу можно использовать макрос CONTAINING_RECORD:

#include <ntdef.h>

PCHAR CONTAINING_RECORD(address, type, field);

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

socket_data *sock_data = nullptr;

OVERLAPPED *lp_overlapped = nullptr;

ret = GetQueuedCompletionStatus(c_port_handle, &transfered,

reinterpret_cast<PULONG_PTR>(&completion_key),

&lp_overlapped, INFINITE);

sock_data = CONTAINING_RECORD(lp_overlapped, socket_data, overlapped);

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

Чтобы понять, «как правильно», необходимо помнить о простом правиле.

Разработчик пишет код, чтобы его могли читать люди.

Тогда становится понятно, что «венгерская нотация» — далеко не лучший стиль. Возможно, она была полезна, когда Стандартная библиотека C++ была очень бедной, а IDE только зарождались, так как эта нотация позволяла видеть по имени тип переменной.

Венгерской она называется потому, что ввел ее венгр Чарльз Симони, старший архитектор в Microsoft во времена разработки MS-DOS. Эта нотация служила для упрощения разработки и рефакторинга: нормальных IDE еще не было, а названный специальным образом тип или переменная упрощали разработку, так как программист сразу понимал, что это.

Сейчас от данной практики отходят, но она сохранилась в ОС Windows как наследие и еще долго будет использоваться в WinAPI.

В новых проектах от этого стиля именования отходят даже в Microsoft.

Внимание! Не используйте «венгерскую нотацию» и стиль Microsoft, а также не равняйтесь на код Microsoft.

Подробнее о рекомендуемом Microsoft стиле см. в General Naming Conventions по .NET133, но этот стиль применим и к C++.

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

При работе с Windows API желательно понимать «венгерскую нотацию», чтобы легко читать типы. Она довольно проста. Например, тип PCSTR говорит о том, что аргумент является указателем на константную строку:

• P — pointer.

• C — constant.

• STR — тип строки, может быть просто S.

То есть этот параметр может быть только входным, поскольку он константный.

Если какой-то признак отсутствует, как правило, он не указывается. Та же строка, но уже не константная, будет иметь тип PSTR или LPSTR — Long Pointer.

Данная нотация описана во множестве источников, с которыми рекомендуется ознакомиться. В том числе существует краткое описание в разделе Coding Style Conventions134 MSDN.

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

Обратите внимание, что следует избегать явных выделений и освобождений памяти через функции WinAPI, приведений типов в стиле C и т.п. Всегда используйте возможности, предоставляемые новыми Стандартами C++.

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

Некоторые константы явно рекомендуются к использованию при вызове WinAPI. Например, константы NO_ERROR и ERROR_SUCCESS, также определенные в winerror.h, широко используются API IP Helper:

#define ERROR_SUCCESS 0L

#define NO_ERROR 0L

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

Эта же логика применима и к другим вспомогательным функциям и макросам, не имеющим POSIX-аналогов, например, к рассмотренным далее функциям-оберткам над опциями, которые упрощают код, но делают его непортируемым.

Типы

Функции WinSock используют в качестве типа сокета SOCKET — целочисленное положительное значение. С точки зрения ядра это индекс в массиве указателей на параметры сокетов, то есть то же самое, что и дескриптор в POSIX-системах.

В ОС Windows дескриптор называется хэндл и может принадлежать, например, окну. Кроме окон, некоторые объекты ядра, адресуемые дескрипторами, могут принимать сообщения. Но в отличие от Unix-подобных систем, использоваться с файловым API может не любой дескриптор. Поэтому в ранних версиях ОС Windows файловый API был неприменим к сокетам. Однако, начиная с WinSock 2, с дескрипторами сокетов могут работать такие файловые API, как ReadFile() и WriteFile().

В ОС Windows файловый API работает с дескрипторами сокетов через виртуальную файловую систему IFS.

IFS — инсталлируемая файловая система. Это общий внутриядерный API, позволяющий операционной системе поддерживать файловые системы как драйверы, то есть не встраивать поддержку ФС непосредственно в код ядра.

IFS частично используется для сокетов, которые реализуют псевдофайловую систему, что позволяет работать с ними, например, таким функциям, как ReadFile() и WriteFile(), при том что дескриптор сокета в ОС Windows не является реальным дескриптором файла.

Если IFS поддерживается конкретным сокетным провайдером, в структуре WSAPROTOCOLINFO будет установлен флаг XP1_IFS_HANDLES, рассматриваемый в главе 18. С сокетами через него лучше не работать — это может привести к снижению производительности.

Особенностью хэндла является то, что он представляет собой беззнаковое число, то есть функция не может вернуть –1 в случае неудачи, как в POSIX. Вместо этого в случае ошибки POSIX-совместимые функции возвращают константу INVALID_SOCKET или INVALID_HANDLE_VALUE:

#define INVALID_SOCKET ((SOCKET)(~0))

#define INVALID_HANDLE_VALUE ((HANDLE)(LONG_PTR)-1)

Конечно, будет работать и знаковый тип, поэтому сравнение результата, возвращаемого POSIX-совместимым WinSock API, не приведет к ошибке, но вызовет предупреждение компилятора. Чтобы избежать таких предупреждений, необходимо использовать константу INVALID_SOCKET.

Предполагается, что используется система с дополнением до двух или кодирование отрицательных чисел дополнительным кодом. В этом случае (SOCKET)(~0), означающий установку всех битов переменной типа SOCKET в 1, будет кодировать –1. И это всегда будет работать в ОС Windows, по той причине, что данная ОС работает только на таких архитектурах.

В системе, где отрицательные числа не кодируются в дополнительном коде, ~0 может быть не равен –1 знакового типа. Но беспокоиться об этом не стоит, просто используйте константу так, как того требует спецификация.

Из сказанного также следует, что переменная типа SOCKET может принимать целые значения от 0 до INVALID_SOCKET — 1. На практике же значение дескриптора обычно достаточно большое число.

Внимание! В дальнейшем понятия «дескриптор» и «хэндл» будут использоваться как взаимозаменяемые.

Тип ssize_t в ОС Windows отсутствует и обычно заменяется типом int. В обертке над сокетами для учета всех этих особенностей были введены следующие псевдонимы типов:

#if defined(_WIN32)

extern "C"

{

#    include <winsock2.h>

#    include <ws2tcpip.h>

}

#    include <cinttypes>

// Дескриптор сокета в ОС Windows.

using SocketDescriptorType = SOCKET;

// Отсутствующий ssize_t.

using ssize_t = int;

// Тип для функции ioctlsocket() и подобных, описываемых в главе 18.

using IoctlType = u_long;

#    if !defined(in_addr_t)

// Тип IPv4-адреса, если не определен.

using in_addr_t = uint32_t;

#    endif

#else  // не WIN32.

// Для не-Windows платформ, использующих POSIX-сокеты.

extern "C"

{

#    include <arpa/inet.h>

#    include <sys/socket.h>

// Нужно для getaddrinfo() и freeaddrinfo().

#    include <netdb.h>

// Здесь объявлена функция close().

#    include <unistd.h>

}

// Тип дескриптора сокета в Unix-подобных ОС.

using SocketDescriptorType = int;

using IoctlType = int;

#endif

// Константа, аналогичная INVALID_SOCKET в ОС Windows.

#if !defined(INVALID_SOCKET)

#    define INVALID_SOCKET (-1)

#endif

// Константа для проверки корректности дескриптора сокета.

#if !defined(SOCKET_ERROR)

#    define SOCKET_ERROR (-1)

#endif

Несколько констант, определенных для Windows, предлагается использовать для работы с POSIX-сокетами в кросс-платформенном коде.

Общие типы в ОС Windows также отличаются. В прототипах многих функций и при определении структур вы можете увидеть нестандартные булевы и целочисленные типы:

typedef int BOOL;

typedef BYTE BOOLEAN;

typedef unsigned char BYTE;

typedef int INT;

typedef unsigned long DWORD;

...

Эти типы используются по назначению, то есть BOOL-размерности int всегда используются как bool и никогда как int. Иными словами, они могут быть заменены на стандартные типы. Многие из этих типов определены в заголовочных файлах WinNT.h и WinDef.h. А их назначение описано в разделе MSDN «Windows Data Types»135.

Также существует несколько структур, которые широко используются в API и по ходу книги, и мы разберем их сейчас, чтобы не возвращаться к ним каждый раз при описании вызовов. Например, структура WSABUF, которая используется WSA-функциями приема и передачи данных:

typedef struct _WSABUF

{

// Длина буфера в байтах.

ULONG len;

// Буфер.

char *buf;

} WSABUF, *LPWSABUF;

Большая структура WSAPROTOCOL_INFO используется для того, чтобы указать некоторым вызовам API, какой из провайдеров будет обрабатывать запрос. Данную структуру мы рассмотрим в разделе, посвященном сокетным провайдерам.

Порядок байтов

Для преобразования из локального порядка байтов в сетевой доступны функции типа htonl() и htons():

#include <winsock2.h>

// Преобразование в сетевой порядок байтов.

u_long htonl(u_long hostlong);

u_short htons(u_short hostshort);

// Преобразование в порядок байтов узла.

u_long ntohl(u_long netlong);

u_short ntohs(u_short netshort);

Эти функции не требуют инициализации сети и работают в точности как описано в главе 1.

Кроме них имеются непереносимые функции для нестандартных типов аргументов:

#include <winsock2.h>

unsigned __int64 htonll(unsigned __int64 value);

unsigned __int64 ntohll(unsigned __int64 value);

// Берет 4 байта float, переставляет при необходимости и возвращает uint32.

unsigned __int32 htonf(float value);

// Обратная функция.

float ntohf(unsigned __int32 value);

unsigned __int64 htond(double value);

double ntohd(unsigned __int64 value);

Специфичными для WinSock являются функции WSAHtonl(), WSAHtons(), WSANtohs() и WSANtohl(). Их отличие в том, что переданный им сокет используется для определения сетевого порядка байтов.

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

Прототипы функций:

#include <winsock2.h>

// Преобразование в сетевой порядок байтов.

int WSAAPI WSAHtons(

SOCKET  s,

u_short hostshort,

u_short *lpnetshort

);

int WSAAPI WSAHtonl(

SOCKET s,

u_long hostlong,

u_long *lpnetlong

);

// Преобразование в порядок байтов узла.

int WSAAPI WSANtohs(

SOCKET  s,

u_short netshort,

u_short *lphostshort

);

int WSAAPI WSANtohl(

SOCKET s,

u_long netlong,

u_long *lphostlong

);

Параметры функций WSAHtonl(), WSAHtons(), WSANtohs() и WSANtohl():

• s — дескриптор сокета.

• hostlong и hostshort — числа в хостовом порядке байтов для преобразования.

• lpnetlong и lpnetshort — указатель на число для сохранения значения, преобразованного в сетевой порядок.

• netlong и netshort — числа в сетевом порядке байтов для преобразования.

• lphostlong и lphostshort — указатель на число для сохранения преобразованного в хостовой порядок байтов значения.

Внимание! Как и все функции WSA, эти функции требуют инициализированной сетевой подсистемы.

Макрос WSAAPI обычно задает конвенцию вызова. Скорее всего, макрос будет раскрыт в директиву __stdcall.

Помимо кодов возврата WSANOTINITIALISED, WSAENETDOWN, описанных выше для WSAStartup(), функции могут вернуть WSAENOTSOCK, если дескриптор s не сокет, и WSAEFAULT, если параметр назначения является некорректным, то есть содержит либо 0, либо адрес, не относящийся к пользовательскому пространству.

Данные функции не являются заменой ntohl(), htonl() и прочим. И в случае доменов AF_INET и AF_INET6 лучше их не использовать.

Требуются эти функции в случае наличия других семейств протоколов, в которых сетевой порядок байтов может отличаться от big-endian.

Резюме

История сетевого программирования в операционной системе Windows началась давно. Сетевой API ОС Windows крайне обширен и активно развивался, начиная с эпохи NetBIOS, предшественника сокетов.

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

С появлением API сокетов Windows Sockets, или WinSock, NetBIOS постепенно уступил свои позиции и в конечном итоге был вытеснен. На данный момент актуален стандарт WInSock 2.2.

Отличительной особенностью подсистемы WinSock является необходимая по историческим причинам инициализация сети в каждом приложении. В современных приложениях это может быть полезно для выбора провайдера, реализующего функции сокета. Инициализация выполняется с помощью вызова функции WSAStartup(), для каждого вызова которой должна быть произведена деинициализация вызовом функции WSACleanup().

Объекты ядра в ОС Windows идентифицируются дескрипторами, или хэндлами, имеющими тип HANDLE. Этот тип представляет собой беззнаковое целое, а тип SOCKET является подтипом HANDLE. При возникновении ошибок сокетные функции возвращают некорректный хэндл — константу INVALID_SOCKET.

В операционной системе Windows сокет не является файловым дескриптором, хотя некоторые файловые API, такие как ReadFile() и WriteFile(), могут работать с сокетами через IFS.

Интерфейс WinSock частично совместим с BSD-сокетами, но часть его API специфична и адаптирована для операционной системы Windows. Так, состав заголовочных файлов в ОС Windows и в POSIX-системах не совпадает полностью. А, например, функции для работы с порядком байтов бывают как POSIX-совместимые, так и специфичные для ОС Windows.

Многие функции имеют несколько вариантов: некоторые из них работают с символами UNICODE, тогда как другие — с ANSI. Все POSIX-функции работают с ANSI.

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

По причине того, что сетевой API в ОС Windows постоянно развивается, возможности, добавляемые в новые ОС, позволяют создавать высокопроизводительные и надежные сетевые приложения. Об этом мы и поговорим в нескольких следующих главах, посвященных WinSock, а также в главах следующих книг.

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

1. Почему, несмотря на устаревание некоторых разделов API, Microsoft сохраняет их в новых версиях ОС Windows?

2. В чем заключаются главные отличия сетевого Windows API от POSIX API?

3. Какие основные разделы входят в состав сетевого API ОС Windows?

4. Какие пакеты Python можно использовать для работы с сетевым Windows API?

5. Можно ли применять числовые значения констант в коде, если какой-то из констант не хватает? Почему?

6. Что такое NetBIOS и зачем он был необходим?

7. Какая версия стандарта WinSock поддерживается в ОС Windows? Можно ли использовать сокеты более старых версий?

8. Для чего WinSock-приложениям требуется инициализация сетевой библиотеки? Приносит ли инициализация пользу в современных версиях ОС Windows?

9. Как правильно установить версию при инициализации?

10. Как определить, что сетевая подсистема не была инициализирована, если результат вызова WSAStartup() неизвестен?

11. Сколько раз нужно вызывать WSACleanup()?

12. Как правильно интерпретировать ошибку WSAEFAULT, возникающую при работе с сетевым API?

13. Что такое «венгерская нотация» и как она связана с названиями в WinSock2?

14. Какие заголовочные файлы необходимо включать в большинство WinSock-приложений?

15. Чем отличаются функции с суффиксом A и с суффиксом W? Существуют ли другие типы функций?

16. Для чего используется макрос TEXT()?

17. Какой тип символов используют POSIX-совместимые функции?

18. В чем общее отличие POSIX-совместимых функций от их аналогов в Windows с префиксом WSA?

19. Какую разделяемую библиотеку должны подключать сокетные приложения? Как подключить библиотеку правильно?

20. Для чего нужны расширения WinSock и почему просто не добавить функции в основную библиотеку?

21. Как избавиться от предупреждений об использовании нерекомендованных функций? Допустимо ли это и в каких случаях?

22. Хорошая ли идея проверить ошибку, используя макрос FAILED() в переносимом коде? Почему?

23. Какой тип является дескриптором сокета в ОС Windows, что он собой представляет и чем отличается от типа дескриптора POSIX-сокетов?

24. Какие функции лучше использовать в коде и когда: ntohl() и htonl() или WSAHtons() и WSAHtonl()?

25. Напишите клиент и сервер, используя WinSock. Сервер должен ожидать соединения по TCP на свободном порту в блокирующем режиме и выдавать на экран приходящий текст. Клиент должен отправлять данные по TCP-серверу.

62 https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-api-list

63 https://learn.microsoft.com/en-us/windows/win32/dns/dns-reference

64 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/madcap/madcap-start-page

65 https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-start-page-2

66 https://learn.microsoft.com/en-us/windows/win32/ipc/interprocess-communications

67 https://learn.microsoft.com/en-us/windows/win32/ipc/mailslots

68 https://learn.microsoft.com/en-us/windows/win32/ipc/pipes

69 https://learn.microsoft.com/en-us/windows/win32/iphlp/ip-helper-start-page

70 https://learn.microsoft.com/en-us/windows/win32/api/_mib/

71 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/qos/qos-start-page

72 https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-start-page

73 https://learn.microsoft.com/en-us/windows/win32/http/http-api-start-page

74 https://learn.microsoft.com/en-us/windows/win32/websock/web-socket-protocol-component-api-portal

75 https://learn.microsoft.com/en-us/windows/win32/wininet/portal

76 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms686418(v=vs.85)

77 https://learn.microsoft.com/en-us/windows/win32/fwp/windows-filtering-platform-start-page

78 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ics/portal

79 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ics/network-address-translation-traversal-reference

80 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms703216(v=vs.85)

81 https://learn.microsoft.com/en-us/windows/win32/rpc/rpc-start-page

82 https://learn.microsoft.com/en-us/windows/win32/wsw/portal

83 https://learn.microsoft.com/en-us/windows/win32/api/_ixhr2/

84 https://learn.microsoft.com/en-us/windows/win32/api/_netmgmt/

85 https://learn.microsoft.com/en-us/windows/win32/wnet/windows-networking-wnet-

86 https://learn.microsoft.com/en-us/windows/win32/snmp/snmp-start-page

87 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/get_connected/get-connected-wizard-api-portal

88 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/dhcp/dhcp-start-page

89 https://learn.microsoft.com/en-us/windows/win32/nla/portal

90 https://learn.microsoft.com/en-us/windows/win32/bluetooth/bluetooth-reference

91 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/irda/irda-start-page

92 https://learn.microsoft.com/en-us/windows/win32/mbn/mobile-broadband-networks-portal

93 https://learn.microsoft.com/en-us/windows/win32/NativeWiFi/portal

94 https://learn.microsoft.com/en-us/windows/win32/wcn/portal

95 https://learn.microsoft.com/en-us/windows/win32/wcm/windows-connection-manager-portal

96 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/fax/-mfax-fax-service-start-page

97 https://learn.microsoft.com/en-us/windows/win32/rras/portal

98 https://learn.microsoft.com/en-us/windows/win32/tapi/telephony-application-programming-interfaces

99 https://learn.microsoft.com/en-us/windows/win32/netshare/network-share-management

100 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/smb/smb-management-api-portal

101 https://learn.microsoft.com/en-us/windows/win32/api/_websock/

102 https://learn.microsoft.com/en-us/windows/win32/api/_dfs/

103 https://learn.microsoft.com/en-us/windows/win32/p2psdk/portal

104 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/wnv/windows-network-virtualization-portal

105 https://learn.microsoft.com/en-us/windows/win32/netmon2/network-monitor

106 https://learn.microsoft.com/en-us/windows/win32/ndf/portal

107 https://learn.microsoft.com/en-us/windows/win32/nap/network-access-protection-start-page

108 https://learn.microsoft.com/en-us/windows/win32/nps/ias-internet-authentication-service-reference

109 https://learn.microsoft.com/en-us/previous-versions/windows/desktop/wlbsprov/network-load-balancing-provider-portal

110 https://learn.microsoft.com/en-us/windows/win32/api/_mapi/

111 https://learn.microsoft.com/en-us/previous-versions/iis/6.0-sdk/ms525568(v=vs.90)

112 https://learn.microsoft.com/en-us/previous-versions/iis/6.0-sdk/ms525282(v=vs.90)

113 https://learn.microsoft.com/en-us/windows/win32/api/_termserv/

114 https://learn.microsoft.com/en-us/windows/win32/devio/communications-resources

115 https://github.com/mhammond/pywin32

116 https://mhammond.github.io/pywin32/win32file.html

117 https://mhammond.github.io/pywin32/win32pipe.html

118 https://mhammond.github.io/pywin32/win32wnet.html

119 https://mhammond.github.io/pywin32/win32ras.html

120 https://mhammond.github.io/pywin32/win32net.html

121 https://mhammond.github.io/pywin32/win32inet.html

122 https://mhammond.github.io/pywin32/internet.html

123 https://mhammond.github.io/pywin32/mapi.html

124 https://mhammond.github.io/pywin32/html/isapi/doc/isapi.html

125 https://mhammond.github.io/pywin32/win32ts.html

126 https://mhammond.github.io/pywin32/exchange.html

127 https://github.com/python-trio/trio

128 https://docs.python.org/3/library/ctypes.html

129 https://github.com/tjguk/winsys/

130 https://timgolden.me.uk/python/winsys/ipc.html

131 https://learn.microsoft.com/en-us/windows/win32/api/

132 https://learn.microsoft.com/en-us/windows/win32/winsock/provider-specific-extension-mechanism-2

133 https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/general-naming-conventions

134 https://learn.microsoft.com/en-us/windows/win32/stg/coding-style-conventions

135 https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types

Глава 15. Адресация в ОС Windows

В ближайшие 10 лет я не вижу большого коммерческого потенциала у интернета.

Билл Гейтс, из выступления на COMDEX, 1994

Введение

В этой главе мы рассмотрим особенности API для работы с адресами в ОС Windows. Мы уделим внимание структурам данных и функциям, используемым в WinSock для выполнения операций с IDN, с IPv4- и IPv6-адресами, таким как преобразование имен в адреса и обратно или разбор строки адреса.

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

Затем, по аналогии с главой 2, мы рассмотрим, как работать с DNS и переводить доменные имена в IP-адреса и обратно.

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

В конце главы затронем тонкие моменты работы с DNS и настройку резолвера.

Структуры адресов IPv4 и IPv6

Структура для адресации в сокете аналогична той, что используется в POSIX-сокетах и была рассмотрена в главе 2.

Приложения указывают IPv4-адрес и порт для обмена через структуру sockaddr_in:

typedef struct sockaddr_in

{

short          sin_family;

u_short        sin_port;

struct in_addr sin_addr;

char           sin_zero[8];

} SOCKADDR_IN, *PSOCKADDR_IN, *LPSOCKADDR_IN;

Структура sockaddr_in6 тоже аналогична описанной в главе 6. Но структуры адресов для конкретных протоколов определяются иначе.

Для IPv4 структура определена в winsock2.h:

struct in_addr

{

union

{

// IPv4-адрес в формате четырех символов u_char.

struct

{

u_char s_b1;

u_char s_b2;

u_char s_b3;

u_char s_b4;

} S_un_b;

// IPv4-адрес в формате двух u_shorts.

struct

{

u_short s_w1;

u_short s_w2;

} S_un_w;

// Адрес IPv4 в формате u_long.

u_long S_addr;

} S_un;

};

А для IPv6 в in6addr.h:

typedef struct in6_addr

{

union

{

u_char  Byte[16];

u_short Word[8];

} u;

} IN6_ADDR, *PIN6_ADDR, FAR *LPIN6_ADDR;

Структура sockaddr_storage определена в ws2def.h. Она аналогична POSIX-структуре, но имеет дополнительное имя типа — SOCKADDR_STORAGE.

Существует еще одна крайне похожая на sockaddr_storage структура — sockaddr_storage_xp.

Она требуется для работы функций WSK — WinSock Kernel, то есть сокетов в режиме ядра.

Мы данной работы в режиме ядра не касаемся и эту структуру не рассматриваем.

Некоторые функции, например WSASetService(), работают с адресной структурой CSADDR_INFO, которая определена следующим образом:

#include <nspapi.h>

#include <winsock2.h>

// Адреса, которые возвращаются с ответом.

typedef struct _CSADDR_INFO

{

// Локальный адрес, например, установленный bind().

SOCKET_ADDRESS LocalAddr;

// Адрес удаленного абонента, который установлен, например connect().

SOCKET_ADDRESS RemoteAddr;

// Тип сокета, например SOCK_STREAM.

int iSocketType;

// Используемый протокол, например IPPROTO_TCP.

int iProtocol;

} CSADDR_INFO, *PCSADDR_INFO, *LPCSADDR_INFO;

Эту структуру также может быть удобно использовать для описания соединения между узлами.

Рис. 15.1. Структура адреса CSADDR_INFO

Как видно из рис. 15.1, структуры, которые хранят локальный и удаленный адреса, здесь тоже нестандартные:

typedef struct sockaddr

{

#if (_WIN32_WINNT < 0x0600)

u_short sa_family;

#else

// Семейство адресов

ADDRESS_FAMILY sa_family;

#endif

// Данные адреса.

char sa_data[14];

} SOCKADDR, *PSOCKADDR, FAR *LPSOCKADDR;

typedef struct _SOCKET_ADDRESS

{

// Указатель на адрес.

LPSOCKADDR lpSockaddr;

// Длина адреса.

int iSockaddrLength;

} SOCKET_ADDRESS, *PSOCKET_ADDRESS, *LPSOCKET_ADDRESS;

Формирование и конвертация адресов

Для преобразования адресов WinSock API предоставляет большое количество функций, разобраться в которых сразу может быть сложно. Попробуем рассмотреть большую их часть в этом разделе.

WinSock также содержит расширенные функции, обозначаемые суффиксом Ex.

Например GetAddrInfoEx() — аналог рассмотренной далее GetAddrInfo().

Помимо того что данные функции содержат некоторые дополнительные параметры, например, GetAddrInfoEx() позволяет выбирать провайдер имен, отличный от DNS, они часто используются как асинхронные. Разбирать их в этой главе мы не будем — это одна из тем книги 2.

Некоторые функции для работы с адресами

В WinAPI существует набор функций для работы с числовыми значениями IP-адресов. Большая часть из этих функций не документирована, но они могут быть полезны. Здесь мы рассмотрим некоторые из них.

Для сравнения IP-адресов могут быть использованы следующие функции:

#include <mstcpip.h>

// Сравнить адреса одного семейства.

bool INET_ADDR_EQUAL(ADDRESS_FAMILY af, const void* a, const void* b);

// Сравнить невыровненные адреса.

bool INET_UNALIGNED_ADDR_EQUAL(ADDRESS_FAMILY af,

const void* a, const void* b);

// Здесь используется универсальная структура SOCKADDR,

// и семейства адресов сравниваются в первую очередь.

bool INETADDR_ISEQUAL(const SOCKADDR *a, const SOCKADDR *b)

Параметры функций сравнения:

• af — семейство адресов: AF_INET либо AF_INET6;

• a и b — сравниваемые адреса.

Функции для проверки IP-адреса на то, инициализирован ли он или является подстановочным адресом для всех интерфейсов:

// Это any-адрес, то есть адрес всех сетевых интерфейсов (0.0.0.0)?

bool INET_IS_ADDR_UNSPECIFIED(ADDRESS_FAMILY af, const void* a);

bool INET_IS_UNALIGNED_ADDR_UNSPECIFIED(ADDRESS_FAMILY af, const void* a);

// То же самое для SOCKADDR.

bool INETADDR_ISUNSPECIFIED(const SOCKADDR *a);

bool INETADDR_ISANY(const SOCKADDR *a);

// Вернуть any-адрес.

const UCHAR* INET_ADDR_UNSPECIFIED(ADDRESS_FAMILY af);

// Установить переданный по указателю адрес в any.

void INETADDR_SETANY(PSOCKADDR a);

Проверка на то, что это адрес локальной петли, и получение этого адреса:

// Это адрес локальной петли?

bool INET_IS_ADDR_LOOPBACK(ADDRESS_FAMILY af, const void* a);

bool INETADDR_ISLOOPBACK(const SOCKADDR *a);

// Установить значение адреса в адрес локальной петли.

void INETADDR_SETLOOPBACK(PSOCKADDR a);

Проверка на то, что адрес широковещательный или адресует группу многоадресной рассылки:

// Это широковещательный адрес?

bool INET_IS_ADDR_BROADCAST(ADDRESS_FAMILY af, const void* a);

// Это адрес многоадресной рассылки?

bool INET_IS_ADDR_MULTICAST(ADDRESS_FAMILY af, const void* a);

Определение длины адресов:

// Определить длину адреса, то есть размер структур IN_ADDR или IN6_ADDR

// для указанного семейства.

size_t INET_ADDR_LENGTH(ADDRESS_FAMILY af);

// Определить длину структуры SOCKADDR_IN6 или SOCKADDR_IN.

size_t INET_SOCKADDR_LENGTH(ADDRESS_FAMILY af);

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

// Вернуть поле адреса: sin_addr или sin6_addr.

PUCHAR INETADDR_ADDRESS(const SOCKADDR* a);

// Установить поле адреса.

VOID INETADDR_SET_ADDRESS(PSOCKADDR a, const UCHAR *Address);

Получение из той же структуры порта:

// Вернуть поле порта.

USHORT INETADDR_PORT(const SOCKADDR *a);

// Установить порт.

void INETADDR_SET_PORT(PSOCKADDR a, USHORT Port);

Определение того, что переданный IPv6-адрес лежит в диапазоне IPv4-адресов.

// Это IPv6-адрес из диапазона IPv4-адресов?

bool INETADDR_ISV4MAPPED(const SOCKADDR *a);

Данные функции, хотя и не документированы, используются уже давно и, вероятно, будут сохранены в API. Какие-то из них можно использовать в коде приложений.

POSIX-совместимые функции

Для получения IP-адресов из строки рекомендуется использовать функции, описанные в главе 2, особенно в кросс-платформенном коде:

#include <ws2tcpip.h>

int WSAAPI inet_pton(int family, PCSTR pszAddrString, PVOID pAddrBuf);

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

• family — семейство адресов: AF_INET либо AF_INET6.

• pszAddrString — указатель на строку-источник, содержащую текстовое представление IP-адреса.

• pAddrBuf — указатель на буфер, в котором хранится числовое представление IP-адреса в сетевом порядке байтов.

В случае успеха, так же как и inet_pton() в POSIX, функция вернет 1, а буфер pAddrBuf будет содержать IP-адрес в сетевом порядке байтов.

PCSTR WSAAPI inet_ntop(

int family,

const void *pAddr,

PSTR pStringBuf,

size_t stringBufSize

);

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

• pAddr — указатель на IP-адрес в сетевом байте для преобразования в строку.

• pStringBuf — указатель на буфер, в который сохраняется строковое представление IP-адреса, завершающееся нулем. Размер буфера должен быть не менее 16 символов для IPv4-адреса и не менее 46 символов для IPv6.

• stringBufSize — длина буфера pStringBuf.

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

Их Windows-аналоги — InetPton() и InetNtop() — были рассмотрены в разделе об ANSI- и Unicode-версиях функций.

Внимание! Данные функции поддерживают только Internet-адреса, то есть IPv4 и IPv6.

Функция inet_addr(), используется для формирования IPv4-адреса:

WINSOCK_API_LINKAGE unsigned long WSAAPI inet_addr(const char FAR * cp);

Параметр cp — строка, заканчивающаяся нулем, представляющая числа адреса в «точечной» нотации.

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

Функция вернет значение типа unsigned long. Например:

int result = WSAStartup(MAKEWORD(2, 2), &wsaData);

...

// Число, содержащее IP-адрес.

unsigned long ul_addr = inet_addr(argv[1]);

// Различные варианты ошибок.

switch (ul_addr)

{

case INADDR_NONE:

case INADDR_ANY:

{

WSACleanup();

throw ...

}

}

Обратная функция:

char FAR * WSAAPI inet_ntoa(in_addr in);

Она преобразует IP-адрес в строковый вид. Так же как и в POSIX, в ОС Windows данные функции являются устаревшими.

В некоторых версиях ОС Windows это могут быть не функции, а макросы. В любом случае пользоваться ими нежелательно по следующим причинам:

• они поддерживают только адреса IPv4 и помечены как нерекомендованные;

• они не являются переносимыми.

Функции WSAStringToAddress() и WSAAddressToString()

Еще одна функция, которая преобразует сетевой адрес из текстовой формы в двоичную:

#include <winsock2.h>

int WSAAPI WSAStringToAddress(

LPSTR addressString,

int addressFamily,

LPWSAPROTOCOL_INFO lpProtocolInfo,

LPSOCKADDR lpAddress,

LPINT lpAddressLength

);

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

• addressString — строка адреса в стандартной текстовой форме.

• addressFamily — семейство адресов, к которому принадлежит адрес в строке addressString.

• lpProtocolInfo — необязательный указатель на структуру LPWSAPROTOCOL_INFOA либо LPWSAPROTOCOL_INFOW. В общем случае на LPWSAPROTOCOL_INFO. Если этот параметр нулевой, вызов направляется провайдеру первого протокола, поддерживающего заданное семейство адресов.

• lpAddress — указатель на структуру, в которой будет сохранен адрес.

• lpAddressLength — указатель на длину буфера lpAddress. Если вызов функции успешен, в переменную, на которую указывает этот параметр, будет записан размер структуры sockaddr, возвращенный в параметре lpAddress.

Функция вернет 0 в случае успеха, а в случае ошибки — SOCKET_ERROR.

Если размер буфера недостаточен, функция завершается с кодом ошибки WSAEFAULT, который можно получить, вызвав WSAGetLastError(), как будет рассмотрено в главе 24. А в переменную, на которую указывает lpAddressLength, сохраняется требуемый размера буфера.

Это позволяет динамически подбирать размер при необходимости.

Существует и обратная функция, преобразующая адрес из структуры sockaddr в печатную форму:

int WSAAPI WSAAddressToString(

LPSOCKADDR lpsaAddress,

DWORD dwAddressLength,

LPWSAPROTOCOL_INFO lpProtocolInfo,

LPSTR lpszAddressString,

LPDWORD lpdwAddressStringLength

);

Параметры функции WSAAddressToString():

• lpsaAddress — указатель на структуру sockaddr, которая будет преобразована в строку.

• dwAddressLength — длина адреса в структуре sockaddr, то есть sizeof(sockaddr). Размер отличается в разных протоколах.

• lpProtocolInfo — указатель на структуру WSAPROTOCOL_INFO конкретного провайдера. Если этот параметр нулевой, вызов направляется провайдеру первого протокола, поддерживающего заданное семейство адресов.

• lpszAddressString — указатель на буфер, в который будет сохранен адрес.

• lpdwAddressStringLength — размер буфера адреса. После успешного завершения переменная, на которую указывает параметр, будет содержать размер строки, включая завершающий ее нулевой символ.

Возвращаемые значения те же, что и у предыдущей функции.

Особенность этих функций в том, что вместо указателя на буфер они принимают указатель на структуру sockaddr, в которую сохраняют результат. Кроме того, они позволяют явно выбирать провайдер протокола, используемый для преобразования адресов.

Данные функции поддерживают IPv6, начиная с Windows XP SP1.

Функции RTL

Начиная с версии Windows Vista, в IP Helper существует заголовочный файл ip2string.h, в котором объявлены более гибкие функции преобразования адресов в печатную форму и обратно:

// Строка в IPv4.

NTSYSAPI NTSTATUS RtlIpv4StringToAddress(

PCSTR s,

BOOLEAN strict,

PCSTR *terminator,

in_addr *addr

);

// Строка в IPv6.

NTSYSAPI NTSTATUS RtlIpv6StringToAddress(

PCSTR s,

PCSTR *terminator,

in6_addr *addr

);

Параметры функций RtlIpv4StringToAddress() и RtlIpv6StringToAddress():

• s — строка, которая содержит IP-адрес.

• strict — флаг формата. Если значение истинно, строка должна быть IPv4-адресом, представленным строго в десятичном формате из четырех чисел, разделенных точками. В противном случае допускается любая из форм строкового представления IPv4-адреса: десятичная, восьмеричная или шестнадцатеричная.

• terminator — параметр, который получает указатель на символ, завершающий преобразованную строку.

• addr — указатель на структуру, в которую будет сохранено двоичное представление адреса.

Существуют и расширенные варианты данных функций:

// Преобразовать строку в IPv4.

NTSYSAPI NTSTATUS RtlIpv4StringToAddressEx(

PCSTR addressString,

BOOLEAN strict,

in_addr *address,

PUSHORT port

);

// Преобразовать строку в IPv6.

NTSYSAPI NTSTATUS RtlIpv6StringToAddressExA(

PCSTR addressString,

in6_addr *address,

PULONG scopeId,

PUSHORT port

);

Параметры функций RtlIpv4StringToAddressEx() и RtlIpv6StringToAddressExA():

• addressString — строка, содержащая IP-адрес.

• strict — см. в описании RtlIpv4StringToAddress() выше.

• address — указатель на выходную переменную для сохранения адреса.

• port — указатель на переменную, в которую должен сохраниться номер порта. Он сохраняется в сетевом порядке байтов. Если в строке он не указан, порт устанавливается равным 0.

• scopeId — указатель на идентификатор области IPv6-адреса. Если строка адреса не содержит строкового представления идентификатора области, в этом параметре возвращается 0.

Функции возвращают STATUS_SUCCESS в случае успеха, STATUS_INVALID_PARAMETER в случае некорректного значения параметра адреса либо иную ошибку в других случаях. Инициализации WinSock эти функции не требуют.

Для работы с Ethernet-адресами в том же файле объявлены следующие функции:

// Преобразование Ethernet-адреса в строку.

NTSYSAPI PSTR RtlEthernetAddressToString(

const DL_EUI48 *addr,

PSTR s

);

// Преобразование Ethernet-адреса из строки.

NTSYSAPI NTSTATUS RtlEthernetStringToAddress(

PCSTR s,

PCSTR *terminator,

DL_EUI48 *addr

);

Параметры функций RtlEthernetAddressToString() и RtlEthernetStringToAddress():

• s — строка адреса. Входной или выходной буфер. Формат строки — шесть пар шестнадцатеричных символов, разделенных дефисами, например: F4-CE-46-2D-90-8C.

• terminator — указатель на символ, завершающий преобразованную строку.

• addr — Ethernet-адрес в сетевом порядке байтов.

У функции, преобразующей адрес в строку, возвращаемое значение — адрес буфера в случае успеха или нулевой указатель в случае ошибки. Обратная функция возвращает такие же значения, как и функции преобразования IP-адресов, описанные выше.

Структура Ethernet-адреса определена в заголовочном файле netiodef.h:

// Уникальный идентификатор в рамках организации

// — Organizationally Unique Identifier.

union _DL_OUI

{

UINT8 Byte[3];

// Первый байт.  0bxxxxxxLG.

struct

{

// Младший значащий бит.

UINT8 Group : 1;

UINT8 Local : 1;

};

};

typedef union _DL_OUI DL_OUI, *PDL_OUI;

// Идентификатор расширения — Extension Identifier — для адресов EUI-48.

union _DL_EI48

{

UINT8 Byte[3];

};

typedef union _DL_EI48 DL_EI48, *PDL_EI48;

// Адрес EUI-48.

union _DL_EUI48

{

UINT8 Byte[6];

struct

{

DL_OUI Oui;

DL_EI48 Ei48;

};

};

typedef union _DL_EUI48 DL_EUI48, *PDL_EUI48;

Но включать данный файл обычно не стоит, так как структура непрозрачная и объявлена в нескольких других заголовочных файлах.

Данные функции удобны, но не переносимы. Кроме того, функции, начинающиеся с Rtl, то есть Run-Time Library, не включены в Windows SDK, поскольку они содержатся в библиотеке Ntdll.dll, иными словами, являются оболочками над вызовами ядра. На это же указывает макрос NTSYSAPI.

Макрос NTSYSAPI при использовании функции раскрывается в __declspec(dllimport).

Целесообразность их использования зависит от того, планируется ли в будущем переносить код на другую ОС.

Конвертация между длиной префикса адреса и маской подсети

В IP Helper API имеется несколько полезных функций для конвертации маски в длину префикса и длины в маску:

#include <netioapi.h>

NETIOAPI_API ConvertLengthToIpv4Mask(ULONG MaskLength, PULONG Mask);

NETIOAPI_API ConvertIpv4MaskToLength(ULONG Mask, PUINT8 MaskLength);

Параметры функций ConvertLengthToIpv4Mask() и ConvertIpv4MaskToLength():

• MaskLength — длина префикса;

• Mask — маска.

Функции вернут NO_ERROR в случае успеха.

Вызывать функцию для конвертации длины префикса в маску удобно следующим образом:

union

{

// Маска как число.

ULONG ul;

// Маска по байтам.

uint8_t b[4];

} mask;

ConvertLengthToIpv4Mask(netmask_length, &mask.ul);

Разбор строки адреса

Строку, которая содержит адрес и порт, можно разобрать, используя системный API:

#include <iphlpapi.h>

typedef struct NET_ADDRESS_INFO_

{

NET_ADDRESS_FORMAT Format;

union

{

struct

{

WCHAR Address[DNS_MAX_NAME_BUFFER_LENGTH];

WCHAR Port[6];

} NamedAddress;

SOCKADDR_IN Ipv4Address;

SOCKADDR_IN6 Ipv6Address;

SOCKADDR IpAddress;

};

} NET_ADDRESS_INFO, *PNET_ADDRESS_INFO;

DWORD ParseNetworkString(

const WCHAR *NetworkString,

DWORD Types, PNET_ADDRESS_INFO AddressInfo,

USHORT *PortNumber, BYTE *PrefixLength);

Параметры функции ParseNetworkString():

• NetworkString — строка для разбора.

• Types — тип строки:

• NET_STRING_IP_ADDRESS — IPv4-адрес в стандартной десятичной нотации с точками или адрес IPv6 в стандартной шестнадцатеричной нотации. Идентификатор области IPv6 может присутствовать, а порт или префикс — нет. Соответствует NET_STRING_IPV4_ADDRESS | NET_STRING_IPV6_ADDRESS.

• NET_STRING_IP_ADDRESS_NO_SCOPE — адрес IPv4 или адрес IPv6 без идентификатора области. Соответствует NET_STRING_IPV4_ADDRESS | NET_STRING_IPV6_ADDRESS_NO_SCOPE.

• NET_STRING_IP_SERVICE — служба IPv4 или служба IPv6. Порт обязателен в строке. Идентификатор области IPv6 может присутствовать, префикс — нет. Соответствует NET_STRING_IPV4_SERVICE | NET_STRING_IPV6_SERVICE.

• NET_STRING_IP_SERVICE_NO_SCOPE — служба IPv4 или служба IPv6. Порт обязателен в строке. Идентификатор области IPv6 и префикс не должны присутствовать в строке. Соответствует NET_STRING_IPV4_SERVICE | NET_STRING_IPV6_SERVICE_NO_SCOPE.

• NET_STRING_IP_NETWORK — сеть IPv4 или IPv6. Префикс в нотации CIDR требуется как часть сетевой строки, а порт или идентификатор области — нет. Соответствует NET_STRING_IPV4_NETWORK | NET_STRING_IPV6_NETWORK.

• NET_STRING_ANY_ADDRESS — адрес IPv4, адрес IPv6 или имя DNS. Идентификатор области IPv6 может присутствовать в строке, сетевой порт или префикс — нет. Соответствует типам NET_STRING_NAMED_ADDRESS | NET_STRING_IP_ADDRESS.

• NET_STRING_ANY_ADDRESS_NO_SCOPE — адрес IPv4, адрес IPv6 или имя DNS. Идентификатор области IPv6, порт и префикс не должны присутствовать в строке. Соответствует типам NET_STRING_NAMED_ADDRESS | NET_STRING_IP_ADDRESS_NO_SCOPE.

• NET_STRING_ANY_SERVICE — служба IPv4, IPv6 или имя DNS. Порт должен присутствовать в строке. Идентификатор области IPv6 может присутствовать, а префикс — нет. Соответствует типам NET_STRING_NAMED_SERVICE | NET_STRING_IP_SERVICE.

• NET_STRING_ANY_SERVICE_NO_SCOPE — указывает на службу IPv4, IPv6 или имя DNS. Порт требуется в строке, а идентификатора области IPv6 и префикса в ней быть не должно. Соответствует NET_STRING_NAMED_SERVICE | NET_STRING_IP_SERVICE_NO_SCOPE.

• AddressInfo — сюда будет записан разобранный адрес.

• PortNumber — по указателю в эту переменную будет записан порт в порядке байтов узла. Если порта в строке нет, будет записан 0.

• PrefixLength — сюда будет записана длина префикса.

В случае успеха функция вернет ERROR_SUCCESS. Она может вернуть ERROR_INSUFFICIENT_BUFFER, если размер буфера недостаточен для записи адреса.

Преобразование IDN

Для работы с интернационализированными доменными именами, описанными в RFC 3490 «Internationalizing Domain Names in Applications (IDNA)», существует несколько функций WinAPI.

Рассмотрим их прототипы:

#include <winnls.h>

// Преобразовать IDN в Punycode.

int IdnToUnicode(

DWORD dwFlags,

LPCWSTR lpASCIICharStr,

int cchASCIIChar,

LPWSTR lpUnicodeCharStr,

int cchUnicodeChar

);

// Преобразовать IDN или другую метку в форму NamePrep,

// указанную в RFC 3491.

int IdnToNameprepUnicode(

DWORD dwFlags,

LPCWSTR lpUnicodeCharStr,

int cchUnicodeChar,

LPWSTR lpNameprepCharStr,

int cchNameprepChar

);

// Преобразовать IDN в Punycode.

int IdnToAscii(

DWORD dwFlags,

LPCWSTR lpUnicodeCharStr,

int cchUnicodeChar,

LPWSTR lpASCIICharStr,

int cchASCIIChar

);

Параметры функций IdnToUnicode(), IdnToNameprepUnicode(), IdnToAscii():

• dwFlags — флаги, определяющие параметры преобразования:

• IDN_ALLOW_UNASSIGNED — разрешить включение неназначенных кодовых точек во входную строку. Если они присутствуют и не разрешены, функция вернет ошибку ERROR_INVALID_NAME. Флаг позволяет функции обрабатывать символы, которые в настоящее время недопустимы в IDN, но могут быть разрешены в более поздних версиях стандарта IDNA.

• IDN_USE_STD3_ASCII_RULES — отфильтровать символы ASCII, которые не разрешены в именах STD3. Единственными символами ASCII, разрешенными во входной строке, являются буквы, цифры и дефис. Строка не может начинаться или заканчиваться дефисом.

• IDN_EMAIL_ADDRESS — если флаг установлен, в случае ошибки в адресе e-mail будет возвращен резервный адрес, если он доступен, например <local>@gmail.com.

• IDN_RAW_PUNYCODE — отключить проверку и сопоставление Punycode.

• lpASCIICharStr — указатель на буфер, который получает строку, состоящую только из набора символов ASCII.

• cchASCIIChar — размер входной ASCII-строки либо размер буфера для выходной ASCII-строки в lpASCIICharStr.

• lpUnicodeCharStr — указатель на Unicode-строку, представляющую IDN в кодировке UTF-16.

• cchUnicodeChar — размер входной Unicode-строки, переданной в lpUnicodeCharStr, либо размер выходного буфера для Unicode-строки.

Сначала в функции передается входной буфер, затем — выходной. Если в качестве выходного буфера передан нулевой указатель и в соответствующее значение длины установлено в 0, функции вернут требуемый размер выходного буфера.

Иначе в случае успеха будет возвращено количество символов в буфере результата, а в случае ошибки — 0.

Внимание! POSIX-функции возвращают 0 в случае успеха, но данные функции возвращают 0 в случае ошибки.

Чтобы получить расширенную информацию об ошибке, приложение может вызвать функцию GetLastError() с одним из следующих кодов ошибки:

• ERROR_INSUFFICIENT_BUFFER — предоставленный размер буфера был недостаточно большим или равным 0.

• ERROR_INVALID_FLAGS — значения, предоставленные для флагов, недействительны.

• ERROR_INVALID_NAME — указано недопустимое имя.

• ERROR_INVALID_PARAMETER — одно из значений параметра было недопустимым.

• ERROR_NO_UNICODE_TRANSLATION — в строке обнаружен недопустимый код Unicode.

В главах 24 и 25 мы подробнее рассмотрим обработку ошибок и исключительных ситуаций, в частности данную функцию.

Функции для работы с DNS

Функции, описанные в главе 2, доступны и в ОС Windows. В большинстве случаев уместно использовать их. Но для настройки и тонкой работы с DNS существует специфичный Windows API.

База узлов и работа с ней

В составе WinSock имеется структура hostent, которая описана в главе 2. Она похожа на структуру в POSIX, но параметры семейства адресов имеют тип short, а не int:

typedef struct hostent

{

// Официальное имя узла, FQDN.

char *h_name;

// Псевдонимы.

char **h_aliases;

// Семейство адресов.

short h_addrtype;

// Длина адреса.

short h_length;

// Список адресов.

char **h_addr_list;

} HOSTENT, *PHOSTENT, *LPHOSTENT;

С данной структурой работают функции gethostbyaddr() для получения структуры по адресу узла и gethostbyname() по его имени.

Напоминаем, что данные функции являются устаревшими, поэтому использовать их не рекомендуется. Но при необходимости их описание можно посмотреть в MSDN.

Функции для обхода базы узлов в WinSock отсутствуют.

Получение списка адресов с помощью getaddrinfo()

Прототипы функций для получения адресов с использованием DNS:

#include <ws2tcpip.h>

int WSAAPI getaddrinfo(

PCSTR pNodeName,

PCSTR pServiceName,

const ADDRINFOA *pHints,

PADDRINFOA *ppResult

);

int WSAAPI GetAddrInfoW(

PCWSTR pNodeName,

PCWSTR pServiceName,

const ADDRINFOW *pHints,

PADDRINFOW *ppResult

);

#define GetAddrInfoA getaddrinfo

Существует также функция GetAddrInfoEx(), которая помимо работы в асинхронном режиме, дает возможность задавать таймаут ответа.

Функция getaddrinfo() имеет POSIX-совместимый прототип:

• pNodeName — имя узла либо строка, которая содержит его IPv4-адрес в «точечной» нотации либо IPv6-адрес в шестнадцатеричной нотации.

• pServiceName — строка, которая содержит имя службы или порт.

• pHints — указатель на структуру, аналогичную addrinfo.

• ppResult — список возвращенных адресов.

Функция также возвращает 0 при успехе и код ошибки в ином случае.

В nspapi.h объявлена функция GetAddressByName(), которая запрашивает пространство имен, чтобы получить информацию о сетевом адресе для указанной сетевой службы.

Данная функция является устаревшей и не используется в WinSock2. Поэтому мы ее рассматривать не будем.

Структура ADDRINFOA немного отличается от POSIX-структуры addrinfo:

struct addrinfo

{

// AI_PASSIVE, AI_CANONNAME и т.д.

int ai_flags;

// AF_UNSPEC, AF_INET, AF_INET6, AF_NETBIOS, AF_BTH, AF_IRDA.

int ai_family;

// SOCK_STREAM, SOCK_DGRAM.

int ai_socktype;

// IPPROTO_TCP, IPPROTO_UDP, IPPROTO_RM или IPPROTO_PGM для надежной

// широковещательной передачи.

int ai_protocol;

// Размер ai_addr в байтах.

size_t ai_addrlen;

// Полное каноническое имя. Здесь присутствует отличие:

// в POSIX-структуре данное поле расположено после ai_addr.

char *ai_canonname;

// struct sockaddr_in или _in6.

struct sockaddr *ai_addr;

// Следующий узел списка.

struct addrinfo *ai_next;

} ADDRINFOA, *PADDRINFOA;

Разница в том, что поле ai_canonname расположено перед ai_addr, тогда как в POSIX-структуре — после.

Структура ADDRINFOW, которая является типом аргумента функции GetAddrInfoW(), отличается тем, что для канонического имени в ней используется строка широких символов:

typedef struct addrinfoW

{

int ai_flags;

int ai_family;

int ai_socktype;

int ai_protocol;

size_t ai_addrlen;

// Каноническое имя в UNICODE.

PWSTR ai_canonname;

struct sockaddr  *ai_addr;

struct addrinfoW *ai_next;

} ADDRINFOW, *PADDRINFOW;

В поле ai_flags поддерживаются флаги, которые есть в POSIX, но отличаются от тех, которые есть в Linux. Флаги AI_PASSIVE, AI_CANONNAME, AI_NUMERICHOST, AI_NUMERICSERV, AI_V4MAPPED работают, как описано в главе 2.

Флаг AI_ADDRCONFIG говорит о том, что GetAddrInfoW() будет выполнять разрешение имен только в том случае, если настроен глобальный адрес, а не адрес локальной петли.

Флаги AI_IDN, AI_CANONIDN, AI_IDN_ALLOW_UNASSIGNED, AI_IDN_USE_STD3_ASCII_RULES не поддерживаются.

Windows также имеет несколько флагов, которые используются только в этой ОС, причем в разных версиях они разные. Следующие флаги поддерживаются, начиная с Windows Vista, и относятся только к адресам электронной почты, то есть к провайдеру пространства имен NS_EMAIL:

• AI_NON_AUTHORITATIVE — если флаг установлен, провайдер возвращает результаты как с авторитетных, так и с неавторитетных DNS-серверов. В противном случае — только с авторитетных.

• AI_SECURE — если установлен бит AI_SECURE, провайдер будет возвращать результаты, полученные безопасно, чтобы свести к минимуму возможность спуфинга.

• AI_RETURN_PREFERRED_NAMES — если флаг установлен, провайдер вернет предпочтительные имена для публикации. В параметре pNodeName не должно указываться имя.

Авторитетный DNS-сервер является конечным владельцем IP-адреса домена, поиск которого производится, и хранит информацию о записях DNS.

Авторитетный DNS-сервер обычно является последним шагом в цепочке рекурсивных запросов IP-адреса и содержит информацию, специфичную для доменного имени, которое он обслуживает.

Кроме него могут существовать, например, кэширующие DNS-серверы.

Еще несколько флагов поддерживаются, начиная с Windows 7 и 8:

• AI_FQDN — если установлен этот флаг и указана только одна компонента имени, функция getaddrinfo() вернет полное доменное имя в атрибуте ai_canonname результирующей структуры addrinfo. При установке флага AI_CANONNAME возвращается каноническое имя из записи DNS, которое может отличаться от FQDN. Поэтому флаги AI_FQDN и AI_CANONNAME нельзя устанавливать одновременно: это приведет к ошибке EAI_BADFLAGS.

• AI_FILESERVER — подсказка провайдеру пространства имен о том, что запрашиваемое имя хоста используется в сценарии совместного доступа к файлам. Провайдер пространства имен может игнорировать этот флаг.

• AI_DISABLE_IDN_ENCODING — отключить автоматическое кодирование IDN в Punycode.

Удалять выделенные структуры нужно, используя те же функции, что и в POSIX:

void WSAAPI freeaddrinfo(PADDRINFOA pAddrInfo);

void WSAAPI FreeAddrInfoW(PADDRINFOW pAddrInfo);

#define FreeAddrInfoA freeaddrinfo

#ifdef UNICODE

#define FreeAddrInfo FreeAddrInfoW

#else

#define FreeAddrInfo FreeAddrInfoA

#endif

Получение сообщений об ошибках

Для преобразования кода ошибки getaddrinfo() в строку используется уже описанная в главе 2 функция gai_strerror().

В Windows эта функция представляет собой вызов FormatMessage() с флагом FORMAT_MESSAGE_FROM_SYSTEM. То есть ошибки getaddrinfo() здесь не отличаются от других системных ошибок.

Пример использования данной функции на практике приведен в главе 2 — это резолвер, который успешно компилируется и работает в ОС Windows.

Регистрация и удаление имен

Иногда требуется зарегистрировать имя некоторой службы и связанные адреса в базе. Для этого используется следующая функция:

#include <ws2tcpip.h>

int WSAAPI SetAddrInfoEx(

PCTSTR pName,

PCTSTR pServiceName,

SOCKET_ADDRESS *pAddresses,

DWORD dwAddressCount,

LPBLOB lpBlob,

DWORD dwFlags,

DWORD dwNameSpace,

LPGUID lpNspId,

timeval *timeout,

LPOVERLAPPED lpOverlapped,

LPLOOKUPSERVICE_COMPLETION_ROUTINE lpCompletionRoutine,

LPHANDLE lpNameHandle

);

Параметры функции SetAddrInfoEx():

• pName — имя, под которым должны быть зарегистрированы адреса, либо имя для удаления адреса.

• pServiceName — имя службы, ассоциированное с именем в pName.

• pAddresses — указатель на необязательный массив адресов.

• dwAddressCount — число адресов в массиве. Если 0, все адреса для pName будут удалены.

• lpBlob — данные, специфичные для провайдера.

• dwFlags — флаги, специфичные для провайдера.

• dwNameSpace — идентификатор пространства имен, например:

• NS_ALL — все активные неймспейсы;

• NS_DNS — данные, получаемые от DNS, в том числе IP-адреса;

• NS_EMAIL — пространство имен электронной почты.

• lpNspId — необязательный идентификатор конкретного провайдера имен, который используется для работы с адресами.

• timeout — тайм-аут ожидания, по завершении которого вызов будет завершен принудительно.

• lpOverlapped — необязательный указатель на структуру, используемую для перекрывающегося ввода-вывода.

• lpCompletionRoutine — необязательный указатель на процедуру, вызываемую при успешном завершении операции.

• lpNameHandle — параметр, требуемый для асинхронных операций. Зарезервирован и должен быть равен 0.

В случае успеха функция возвращает 0, в случае неудачи — код ошибки. Регистрацию и отмену регистрации на момент написания книги поддерживает только провайдер NS_EMAIL, начиная с Windows Vista.

Эта функция не поддерживает асинхронные операции, и относящиеся к ним параметры не используются.

Остальные провайдеры, в том числе работающие в пространстве имен NS_DNS, данную функциональность не поддерживают.

Преобразование адресов в имена

Для обратного преобразования используется функция getnameinfo():

#include <ws2tcpip.h>

// Максимальный размер для буфера, на который указывает pServiceBuffer.

#define NI_MAXSERV 32

// Максимальный размер для буфера, на который указывает pNodeBuffer.

#define NI_MAXHOST 1025

int WSAAPI getnameinfo(

const SOCKADDR *pSockaddr,

socklen_t sockaddrLength,

PCHAR pNodeBuffer,

DWORD nodeBufferSize,

PCHAR pServiceBuffer,

DWORD serviceBufferSize,

int flags

);

int WSAAPI GetNameInfoW(

const SOCKADDR *pSockaddr,

socklen_t sockaddrLength,

PWCHAR pNodeBuffer,

DWORD nodeBufferSize,

PWCHAR pServiceBuffer,

DWORD serviceBufferSize,

int flags

);

#define GetNameInfoA getnameinfo

Макросы, содержащие максимальный размер, также определены в ws2tcpip.h.

Прототипы функций почти не отличаются от POSIX-вариантов, рассмотренных в главе 2. Значения флагов также одинаковые, но флаг NI_NUMERICSCOPE отсутствует.

Возвращаемые значения также совместимы. Данные функции поддерживаются, начиная с Windows XP SP2.

База служб

В ОС Windows имеется возможность получать данные сетевых служб подобно тому, как описано в главе 2. Однако для этого используется иной API.

К базе можно делать запросы, описанные структурой WSAQUERYSET:

#include <winsock2.h>

typedef struct _WSAQuerySet

{

// Размер структуры: sizeof(WSAQUERYSET).

DWORD dwSize;

// Имя службы. Может содержать подстановочные символы.

LPTSTR lpszServiceInstanceName;

// Идентификатор класса. Обязателен для запроса.

LPGUID lpServiceClassId;

// Версия провайдера имен.

LPWSAVERSION lpVersion;

// Необязательный комментарий. Игнорируется запросом.

LPTSTR lpszComment;

// Пространство имен: NS_ALL, NS_DNS, NS_EMAIL и т.п.

// Может использоваться запросом.

DWORD dwNameSpace;

// Необязательный GUID провайдера имен. Если он передан в запросе,

// будет запрошен только указанный провайдер.

LPGUID lpNSProviderId;

// Указатель на необязательную начальную точку запроса

// в иерархическом пространстве имен.

LPTSTR lpszContext;

// Размер в байтах массива ограничений протокола.

DWORD dwNumberOfProtocols;

// Опциональный список протоколов.

// Возвращены будут только службы, которые используют эти протоколы.

LPAFPROTOCOLS lpafpProtocols;

// Указатель на необязательную строку запроса.

LPTSTR lpszQueryString;

// Количество элементов в массиве lpcsaBuffer. Не используется запросом.

DWORD dwNumberOfCsAddrs;

// Указатель на массив структур CSADDR_INFO. Не используется запросом,

// но может быть использован в ответе вместе с полем выше.

LPCSADDR_INFO lpcsaBuffer;

// Не используется запросом.

DWORD dwOutputFlags;

// Данные, специфичные для провайдера.

LPBLOB lpBlob;

} WSAQUERYSET, *PWSAQUERYSET, *LPWSAQUERYSET;

Структура WSAQUERYSET изображена на рис. 15.2. В эту же структуру будет возвращаться результат запроса.

Рис. 15.2. Структура WSAQUERYSET

В lpBlob хранится рассмотренная ранее структура hostent. Формат ее хранения показан на рис. 15.3.

Указатели внутри нее — это смещения данных от начала атрибута lpBlobData. Поэтому чтобы получить данные как через обычные указатели, необходимо добавить к ним базовый адрес структуры.

В массивах h_aliases и h_addr_list указатели также являются смещениями, каждое 32-битное поле в месте ссылки будет состоять из смещений.

Рис. 15.3. Структура hostent в BLOB структуры WSAQUERYSET

Для провайдеров имен версии 2 используется структура WSAQUERYSET2.

Она отличается от WSAQUERYSET только тем, что в новой структуре отсутствует поле lpServiceClassId.

Используемые протоколы описываются в поле lpafpProtocols, которое содержит массив структур AFPROTOCOLS:

#include <nspapi.h>

#include <winsock2.h>

// Структура протоколов, которыми могут ограничиваться запросы.

typedef struct _AFPROTOCOLS

{

// Семейство, например AF_INET.

int iAddressFamily;

// Протокол, например IPPROTO_TCP.

int iProtocol;

} AFPROTOCOLS, *PAFPROTOCOLS, *LPAFPROTOCOLS;

Функция WSALookupServiceBegin() выполняет первый запрос:

#include <winsock2.h>

int WSAAPI WSALookupServiceBegin(

LPWSAQUERYSET lpqsRestrictions,

DWORD dwControlFlags,

LPHANDLE lphLookup

);

Параметры функции WSALookupServiceBegin():

• lpqsRestrictions — критерии поиска.

• dwControlFlags — флаги, управляющие глубиной поиска:

• LUP_DEEP — сделать рекурсивный запрос и вернуть не только первый уровень.

• LUP_CONTAINERS или LUP_NOCONTAINERS — возвращать только контейнеры или не возвращать контейнеры. Обычно этот флаг используется для того, чтобы запрашивать устройства, например, при работе с Bluetooth.

• LUP_NEAREST — упорядочить результаты по метрике расстояния. Как будет рассчитана метрика, зависит от провайдера.

• LUP_RETURN_NAME — получить имя в lpszServiceInstanceName.

• LUP_RETURN_TYPE — получить тип в lpServiceClassId.

• LUP_RETURN_VERSION — получить версию в lpVersion.

• LUP_RETURN_COMMENT — получить комментарий в lpszComment.

• LUP_RETURN_ADDR — получить адреса в lpcsaBuffer.

• LUP_RETURN_BLOB — получить данные провайдера в lpBlob.

• LUP_RETURN_ALIASES — возвращать информацию о псевдонимах при следующих запросах, выполняемых через WSALookupServiceNext(). Каждый возвращаемый псевдоним будет иметь установленный флаг RESULT_IS_ALIAS.

• LUP_RETURN_QUERY_STRING — получить строку запроса.

• LUP_RETURN_ALL — установить все флаги LUP_RETURN_.

• LUP_FLUSHPREVIOUS — указывает провайдеру отбросить последний набор результатов, который был слишком велик для буфера, и перейти к следующему набору результатов. Используется в качестве значения параметра dwControlFlags в WSALookupServiceNext().

• LUP_FLUSHCACHE — игнорировать кэш, если провайдер его использует. Например, в случае DNS будет сделан запрос к серверу, даже если запись уже получена.

• LUP_RES_SERVICE — указывает, находится ли основной ответ в RemoteAddr- или в LocalAddr- атрибутах структуры CSADDR_INFO.

• lphLookup — переменная, в которую будет сохранен дескриптор, используемый при вызове WSALookupServiceNext().

Некоторые пространства имен, такие как распределенная система каталогов Whois++, поддерживают задание в строке lpszQueryString расширенных SQL-запросов.

Эта функция «открывает базу служб». При успешном выполнении она возвращает 0 и записывает в lphLookup дескриптор, который будет использован при вызовах WSALookupServiceNext() для получения последовательности результатов:

int WSAAPI WSALookupServiceNext(

HANDLE hLookup,

DWORD dwControlFlags,

LPDWORD lpdwBufferLength,

LPWSAQUERYSET lpqsResults

);

int WSAAPI WSALookupServiceEnd(

HANDLE hLookup

);

Параметры функции WSALookupServiceNext():

• hLookup — дескриптор, возвращенный функцией WSALookupServiceBegin().

• dwControlFlags — флаги, такие же, как в функции WSALookupServiceBegin().

• lpdwBufferLength — длина буфера lpqsResults в байтах.

• lpqsResults — буфер результатов.

Функция WSALookupServiceEnd() «закрывает базу», то есть завершает выполнение запроса. В случае ошибки функции возвращают SOCKET_ERROR.

Хотя указание большего или меньшего количества флагов не является ошибкой, флаги при вызове WSALookupServiceNext() не могут расширять фильтр и запрашивать больше возвращаемых структур, чем было запрошено в WSALookupServiceBegin().

Флаги комбинируются с флагами WSALookupServiceBegin(). Но флаги, указанные в WSALookupServiceNext(), применяются только к текущему вызову и не сохраняются для последующих.

В базе можно зарегистрировать новую службу либо удалить существующую. За это отвечает следующая функция:

#include <winsock2.h>

int WSAAPI WSASetService(

LPWSAQUERYSET lpqsRegInfo,

WSAESETSERVICEOP essoperation,

DWORD dwControlFlags

);

Параметры функции WSASetService():

• lpqsRegInfo — указатель на структуру WSAQUERYSET для регистрации или дерегистрации службы.

• essoperation — операция, выполняемая функцией:

• RNRSERVICE_REGISTER — зарегистрировать службу. Если такая служба уже зарегистрирована, при установленном флаге SERVICE_MULTIPLE — добавить новый адрес службы, иначе — переписать запись о ней полностью.

• RNRSERVICE_DEREGISTER — удалить службу из реестра. При установленном флаге SERVICE_MULTIPLE — удалить все адреса, не удаляя службу.

• RNRSERVICE_DELETE — удалить службу из динамического и постоянного пространства имен. При установленном флаге SERVICE_MULTIPLE — удалить заданные адреса.

• dwControlFlags — дополнительные флаги:

• SERVICE_MULTIPLE — флаг управляет адресами, позволяя добавлять или удалять адреса, по которым доступна служба.

В зависимости от провайдера эта операция может отправлять широковещательные сообщения или менять содержимое постоянного хранилища.

В nspapi.h есть также функции SetService() и GetService(), позволяющие зарегистрировать и получить службу. Эти функции требуются для совместимости с WinSock 1.1 и уже устарели.

Функция WSASetService() принимает набор адресов службы из массива структур CSADDR_INFO в поле lpcsaBuffer. Функции перечисления служб также заполняют этот массив, если был указан флаг LUP_RETURN_ADDR.

Службы могут принадлежать некоторому классу, описывающему пространства имен, в которых будет зарегистрирована служба. Класс задает ее некоторые характеристики. Например, требует ли служба логического соединения для взаимодействия с ней или ориентирована на обмен без соединения.

Если требуется использовать классы служб, их можно установить при регистрации службы; если классы больше не нужны, их можно удалить. Для этих операций применяются следующие функции:

// Установить класс службы.

int WSAAPI WSAInstallServiceClass(

LPWSASERVICECLASSINFO lpServiceClassInfo

);

// Удалить класс.

int WSAAPI WSARemoveServiceClass(

int LPGUID lpServiceClassId

);

Устанавливаемый класс описывается структурой WSASERVICECLASSINFO:

#include <winsock2.h>

typedef struct _WSANSClassInfo

{

// Название параметра, например TCPPORT.

LPTSTR lpszName;

// Идентификатор пространства имен.

DWORD dwNameSpace;

// Тип параметра, например REG_DWORD.

DWORD dwValueType;

// Размер параметра в байтах.

DWORD dwValueSize;

// Указатель на значение параметра.

LPVOID lpValue;

} WSANSCLASSINFO, *PWSANSCLASSINFO, *LPWSANSCLASSINFO;

typedef struct _WSAServiceClassInfo

{

// Идентификатор класса.

LPGUID lpServiceClassId;

// Имя класса.

LPTSTR lpszServiceClassName;

// Количество элементов в массиве lpClassInfos.

DWORD dwCount;

// Массив параметров.

LPWSANSCLASSINFO lpClassInfos;

} WSASERVICECLASSINFO, *PWSASERVICECLASSINFO, *LPWSASERVICECLASSINFO;

Как показано на рис. 15.4, класс — это просто группа, имеющая следующие параметры:

• идентификатор;

• имя;

• параметры, специфичные для пространства имен.

Рис. 15.4. Структура WSASERVICECLASSINFO

Параметры зарегистрированного класса можно получить, используя функцию WSAGetServiceClassInfo():

int WSAAPI WSAGetServiceClassInfo(

LPGUID lpProviderId,

LPGUID lpServiceClassId,

LPDWORD lpdwBufSize,

LPWSASERVICECLASSINFOA lpServiceClassInfo

);

Параметры функции WSAGetServiceClassInfo():

• lpProviderId — указатель на GUID провайдера пространства имен.

• lpServiceClassId — указатель на GUID, идентифицирующий класс.

• lpdwBufSize — размер указателя lpServiceClassInfo в байтах, как входного, так и выходного. Если функция завершается с ошибкой WSAEFAULT, в этом параметре она вернет минимальный размер буфера, необходимый для получения записи.

• lpServiceClassInfo — указатель на структуру WSASERVICECLASSINFO, содержащую информацию о классе обслуживания.

В случае успеха функция вернет 0.

В качестве примера реализуем программу, которая выводит данные о службе ECHO. Сначала инициализируем сетевую подсистему. Затем вызовем WSALookupServiceBegin():

// Определение GUID служб.

extern "C"

{

#include <svcguid.h>

}

...

int main(int argc, char* argv[])

{

uint32_t result = 0;

HANDLE h_lookup = 0;

WSAQUERYSET first_query = {};

// Идентификатор службы: SVCID_UDP = 7.

GUID guid = SVCID_ECHO_UDP;

try

{

socket_wrapper::SocketWrapper sw;

first_query.dwSize = sizeof(WSAQUERYSET);

first_query.lpServiceClassId = &guid;

// "Открыть базу" служб. Для проверки на корректность можно

// использовать макросы.

if (FAILED(WSALookupServiceBegin(&first_query,

LUP_RETURN_NAME |

LUP_RETURN_COMMENT |

LUP_RETURN_ADDR, &h_lookup)))

{

throw std::system_error(sw.get_last_error_code(),

std::system_category(),

"Error on WSALookupServiceBegin()");

}

GUID различных служб описан в svcguid.h, который мы включили.

Далее в цикле вызываем WSALookupServiceNext(), чтобы получить другие описания при их наличии.

Первый вызов необходим, чтобы выделить буфер подходящего размера:

uint32_t result = 0;

// Цикл получения служб.

while (true)

{

WSAQUERYSET test_query = {};

DWORD length = sizeof(test_query);

// Первый вызов функции должен завершиться с ошибкой

// из-за того, что ему передается запрос с нулевыми полями.

// Но вызов запишет в length требуемую длину буфера под ответ.

if (SUCCEEDED(WSALookupServiceNext(h_lookup, 0, &length,

&test_query)))

{

std::cerr << "Impossible" << std::endl;

break;

}

auto err_code = GetLastError();

if (WSAEFAULT == err_code)

{

// Выделить буфер нужной длины.

auto pdata_shared = std::make_shared<char[]>(length);

WSAQUERYSET* p_data =

reinterpret_cast<WSAQUERYSET*>(pdata_shared.get());

// А это уже реальный вызов, который возвращает ответ.

if (FAILED(WSALookupServiceNext(h_lookup, 0, &length,

p_data)))

{

result = WSAGetLastError();

// Windows может вернуть два разных кода ошибки, если

// результатов больше нет.

if ((WSA_E_NO_MORE == result) || (WSAENOMORE == result))

{

std::cout << "No more records found!" << std::endl;

break;

}

// Реальная ошибка.

else throw std::system_error(sw.get_last_error_code(),

std::system_category(),

"Error on WSALookupServiceNext()");

}

Покажем, как вывести адреса службы. Существуют службы вообще без адресов, например SVCID_HOSTNAME, возвращающая имя узла.

В данном случае адрес будет только один:

// Вывести имя службы, номер пространства имен, а также

// количество адресов.

std::wcout

<< "Service instance name: "

<< p_data->lpszServiceInstanceName << "\n"

<< "Name space num: " << p_data->dwNameSpace << "\n"

<< "Address count:  " << p_data->dwNumberOfCsAddrs

<< std::endl;

// Вывести адреса службы.

for (size_t i = 0; i < p_data->dwNumberOfCsAddrs; ++i)

{

if (IPPROTO_UDP == p_data->lpcsaBuffer[i].iProtocol)

{

std::array<char, INET_ADDRSTRLEN> addr;

const auto& sa = reinterpret_cast<const sockaddr_in*>(

p_data->lpcsaBuffer[i].RemoteAddr.lpSockaddr);

// Для примера выводим только IPv4-адреса.

assert(AF_INET == sa->sin_family);

std::cout

<< inet_ntop(AF_INET, &sa->sin_addr, addr.data(),

addr.size())

<< std::endl;

}

}

}

Завершим работу с базой служб, вызвав функцию WSALookupServiceEnd() на дескрипторе, который был получен при открытии базы:

else if (result != WSA_E_NO_MORE && result != WSAENOMORE)

{

break;

}

else

throw std::system_error(sw.get_last_error_code(),

std::system_category(),

"Error on WSALookupServiceNext()");

};

// Завершить работу с базой служб.

if (WSALookupServiceEnd(h_lookup))

throw std::system_error(

sw.get_last_error_code(), std::system_category(),

"WSALookupServiceEnd(hlookup) failed");

}

...

После запуска примера будет выведено имя службы, номер пространства имен и адрес службы:

D:\build\bin> b01-ch15-service_base.exe

Service instance name: win-ws

Namespace num: 12

Address count:  1

192.168.3.254

Тонкая работа с DNS

Так же как в Linux, в ОС Windows существует API для работы с DNS-резолвером. Его функции и структуры объявлены в заголовочном файле windns.h. Обычно для работы достаточно вызова функции getaddrinfo() и подобных. Необходимость напрямую работать с резолвером возникает редко, но в некоторых сценариях этот API бывает востребован. Например, показательной является установка DNS-серверов для каждого приложения с возможностью использовать как серверы для приложения, так и системные.

Мы не будем подробно рассматривать API полностью, а лишь систематизируем его, чтобы было проще обращаться к MSDN.

Особенности API DNS-резолвера

Некоторые функции резолвера существуют не только для ANSI и широких символов, но и для UTF-8. Большинство этих функций отличаются суффиксами _A, _W и _UTF8.

Например, функция DnsQuery() существует в трех экземплярах: DnsQuery_A(), DnsQuery_W() и DnsQuery_UTF8().

Единственная функция, имеющая «классический» суффикс без подчеркивания, — DnsReplaceRecordSet(), которая раскрывается в DnsReplaceRecordSetA(), DnsReplaceRecordSetW() и DnsReplaceRecordSetUTF8().

«Обычные» ANSI и широкосимвольные функции можно использовать как обычно, а версию UTF-8 придется вызывать явно. Причем эти версии функции принимают строку из однобайтовых символов.

Многие функции из этого раздела выделяют память, которую требуется освободить, для чего используются отдельные функции.

Часть функций, в основном те, которые выполняют запросы, — асинхронные, поэтому существуют функции для их отмены.

Функции для вспомогательных операций

Зачастую перед работой с DNS или непосредственно в процессе работы требуется выполнять различные «простые» операции. Например, сравнить имена без учета регистра, нормализовав их исключением лишних строк:

#include <windns.h>

// Сравнить два имени DNS.

bool DnsNameCompare(PCTSTR pName1, PCTSTR pName2);

Или проверить имя на корректность:

#include <windns.h>

// Проверить состояние указанного DNS-имени.

DNS_STATUS DnsValidateName(PCTSTR pszName, DNS_NAME_FORMAT Format);

// Проверить заданный IP-адрес на корректность

// DNS сервера, способного выполнять запросы.

DNS_STATUS DnsValidateServerStatus(PSOCKADDR server, PCWSTR queryName,

PDWORD serverStatus);

Первая функция выполняет проверки на формальную корректность: длину имени, допустимые символы, правильность расположения точек и т.д. — и в зависимости от этого возвращает разные ошибки.

Возвращаемый тип DNS_STATUS — обычный unsigned long:

typedef _Return_type_success_(return == 0) LONG DNS_STATUS;

typedef DNS_STATUS *PDNS_STATUS;

Возвращаемое значение EXIT_SUCCESS говорит о том, что функции завершились успешно.

Существуют функции для работы с учетными данными:

// Создать контекст для набора учетных данных и вернуть его хэндл.

// В этом контексте будут храниться учетные данные некоторого аккаунта.

DNS_STATUS DnsAcquireContextHandle(DWORD CredentialFlags, PVOID Credentials,

PHANDLE pContext);

// Освободить контекст, хранящий учетные данные.

void DnsReleaseContextHandle(PHANDLE pContext);

Созданный этими функциями контекст, содержащий учетные данные, может использоваться далее, например, в функциях работы с ресурсными записями DNS.

Функции инициирования запросов

Запросы — основа работы с DNS. Для их выполнения существует набор функций в составе API резолвера.

Создать сообщение с DNS-запросом в буфере типа DNS_MESSAGE_BUFFER:

bool DnsWriteQuestionToBuffer_W(

PDNS_MESSAGE_BUFFER pDnsBuffer,

PDWORD pdwBufferSize, PCWSTR pszName, WORD wType,

WORD Xid, bool fRecursionDesired);

Существует также аналогичная функция DnsWriteQuestionToBuffer_UTF8().

Основной интерфейс для запросов к DNS, который предоставляет функциональность для выполнения разрешения имен:

DNS_STATUS DnsQuery(PCSTR pszName, WORD wType, DWORD Options, PVOID pExtra,

PDNS_RECORD *ppQueryResults, PVOID *pReserved);

Выполнить асинхронный DNS-запрос:

DNS_STATUS DnsQueryEx(PDNS_QUERY_REQUEST pQueryRequest,

PDNS_QUERY_RESULT pQueryResults,

PDNS_QUERY_CANCEL pCancelHandle);

Запросить конфигурацию локального хоста или конкретного сетевого интерфейса:

DNS_STATUS DnsQueryConfig(DNS_CONFIG_TYPE Config, DWORD Flag,

PCWSTR pwsAdapterName, PVOID pReserved,

PVOID pBuffer, PDWORD pBufLen);

Дает возможность получить доменное имя, список DNS-серверов, информацию о сетевом адаптере и т.п.

Отправить произвольный DNS-запрос из «сырых» данных, например уже сформированный пакет запроса:

DNS_STATUS DnsQueryRaw(DNS_QUERY_RAW_REQUEST *queryRequest,

DNS_QUERY_RAW_CANCEL *cancelHandle)

Выполнить запрос, используя mDNS. Система mDNS не требует наличия сервера имен. Она выполняет поиск заданных служб, рассылая широковещательные UDP-запросы:

DNS_STATUS DnsStartMulticastQuery(PMDNS_QUERY_REQUEST pQueryRequest,

PMDNS_QUERY_HANDLE pHandle)

Асинхронные запросы могут быть отменены следующими функциями:

// Отменить выполняемый запрос к DNS, инициированный вызовом DnsQuery().

DNS_STATUS DnsCancelQuery(PDNS_QUERY_CANCEL pCancelHandle);

// Отменить запрос, инициированный вызовом DnsQueryRaw().

DNS_STATUS DnsCancelQueryRaw(DNS_QUERY_RAW_CANCEL *cancelHandle);

// Остановить запрос DNS-SD.

DNS_STATUS DnsServiceResolveCancel(PDNS_SERVICE_CANCEL pCancelHandle);

// Отменить запрос к mDNS.

DNS_STATUS DnsStopMulticastQuery(PMDNS_QUERY_HANDLE pHandle);

Работа с записями

Для работы с ресурсными записями DNS существует отдельный набор функций.

Сравнение записей:

// Сравнить две ресурсные DNS-записи.

bool DnsRecordCompare(PDNS_RECORD pRecord1, PDNS_RECORD pRecord2);

// Сравнить два набора ресурсных записей.

bool DnsRecordSetCompare(PDNS_RECORD pRR1, PDNS_RECORD pRR2,

PDNS_RECORD *ppDiff1, PDNS_RECORD *ppDiff2);

Копирование записей:

// Создать копию соответствующей ресурсной записи.

PDNS_RECORD DnsRecordCopyEx(PDNS_RECORD pRecord, DNS_CHARSET CharSetIn,

DNS_CHARSET CharSetOut);

// Создать копию набора ресурсных записей.

PDNS_RECORD DnsRecordSetCopyEx(PDNS_RECORD pRecordSet, DNS_CHARSET CharSetIn,

DNS_CHARSET CharSetOut);

В процессе копирования функция может выполнять конвертацию в другую кодировку.

Функции для замены и изменения записей в наборе:

// Заменить существующий набор ресурсных записей.

DNS_STATUS DnsReplaceRecordSet(PDNS_RECORD pReplaceSet, DWORD Options,

HANDLE hContext, PVOID pExtraInfo,

PVOID pReserved);

// Добавить, изменить либо удалить ресурсную запись в наборе.

DNS_STATUS DnsModifyRecordsInSet(PDNS_RECORD pAddRecords,

PDNS_RECORD pDeleteRecords,

DWORD Options, HANDLE hCredentials,

PVOID pExtraList, PVOID pReserved);

Чтобы извлечь записи из набора, можно использовать следующие функции:

// Извлечь записи ресурсов из DNS-сообщения в виде экземпляров

// структур DNS_RECORD.

DNS_STATUS DnsExtractRecordsFromMessage_W(PDNS_MESSAGE_BUFFER pDnsBuffer,

WORD wMessageLength,

PDNS_RECORD *ppRecord);

// Отделить первую запись из набора.

PDNS_RECORD DnsRecordSetDetach(PDNS_RECORD pRecordList);

Работа со службами

Ниже описан набор функций для работы со службами, которые предоставляет DNS-сервер, а также со службами, которые опубликованы по mDNS.

// Инициировать запрос DNS-SD для обнаружения служб в локальной сети.

DNS_STATUS DnsServiceBrowse(

PDNS_SERVICE_BROWSE_REQUEST pRequest,

PDNS_SERVICE_CANCEL pCancel

);

// Отменить запрос DNS-SD обнаружения служб.

DNS_STATUS DnsServiceBrowseCancel(PDNS_SERVICE_CANCEL pCancelHandle);

Получить информацию о конкретной службе, объявленной в локальной сети:

DNS_STATUS DnsServiceResolve(PDNS_SERVICE_RESOLVE_REQUEST pRequest,

PDNS_SERVICE_CANCEL pCancel);

Можно не только получать сервисы, но и добавлять свои. Для этого служат функции описания и регистрации сервисов, а также вспомогательные функции.

Функция DnsServiceConstructInstance() позволяет описать параметры регистрируемой службы и получить корректную структуру описания:

// Создать структуру DNS_SERVICE_INSTANCE из описывающих ее данных.

PDNS_SERVICE_INSTANCE DnsServiceConstructInstance(

PCWSTR pServiceName,

PCWSTR pHostName,

PIP4_ADDRESS pIp4,

PIP6_ADDRESS pIp6,

WORD wPort,

WORD wPriority,

WORD wWeight,

DWORD dwPropertiesCount,

PCWSTR *keys,

PCWSTR *values

);

// Копировать объект структуры DNS_SERVICE_INSTANCE.

PDNS_SERVICE_INSTANCE DnsServiceCopyInstance(PDNS_SERVICE_INSTANCE pOrig);

// Освободить ресурсы, ассоциированные со структурой DNS_SERVICE_INSTANCE.

void DnsServiceFreeInstance(PDNS_SERVICE_INSTANCE pInstance);

Зарегистрировать описанную службу или удалить регистрацию можно, используя следующие функции:

// Зарегистрировать службу, информация о которой может быть получена

// с помощью DNS.

DWORD DnsServiceRegister(PDNS_SERVICE_REGISTER_REQUEST pRequest,

PDNS_SERVICE_CANCEL pCancel);

// Отменить регистрацию службы, когда она больше не требуется.

DnsServiceDeRegister(PDNS_SERVICE_REGISTER_REQUEST pRequest,

PDNS_SERVICE_CANCEL pCancel);

// Отменить выполняемую операцию регистрации или отмены регистрации.

DWORD DnsServiceRegisterCancel(PDNS_SERVICE_CANCEL pCancelHandle);

На DNS-сервере описания служб обычно хранятся в записях расположения службы, имеющих тип SRV. Каждая такая запись содержит хост, порт и протокол службы.

Работа с прокси

Выполнять запросы к DNS-серверам можно через прокси. Следующая функция позволяет получить настройки прокси:

#include <windns.h>

DWORD DnsGetProxyInformation(

PCWSTR hostName, DNS_PROXY_INFORMATION *proxyInformation,

DNS_PROXY_INFORMATION *defaultProxyInformation,

DNS_PROXY_COMPLETION_ROUTINE completionRoutine,

void *completionContext);

Функции для установки настроек прокси отсутствуют, скорее всего потому, что настройки являются глобальными, или из-за того, что вызов работает через RPC и функциональности установки, технически способной работать удаленно, не подразумевалось.

Настройки для приложений

Следующие функции позволяют установить и получить настройки DNS для приложений без изменения системных настроек:

#include <windns.h>

// Настроить DNS для конкретного приложения.

DWORD DnsSetApplicationSettings(DWORD cServers,

const DNS_CUSTOM_SERVER *pServers,

const DNS_APPLICATION_SETTINGS *pSettings);

// Получить настройки DNS, специфичные для приложения.

DWORD DnsGetApplicationSettings(DWORD *pcServers,

DNS_CUSTOM_SERVER **ppDefaultServers,

DNS_APPLICATION_SETTINGS *pSettings);

Эти функции работают со следующей структурой:

// Заголовочные файлы лучше включать именно в таком порядке,

// чтобы использовать DNS_CUSTOM_SERVER вместе с ServerAddr.

#include <ws2ipdef.h>

#include <windns.h>

typedef struct _DNS_APPLICATION_SETTINGS

{

// Должен быть установлен DNS_APP_SETTINGS_VERSION1.

ULONG Version;

// Опции.

ULONG64 Flags;

} DNS_APPLICATION_SETTINGS;

// Описание сервера для приложения.

typedef struct _DNS_CUSTOM_SERVER

{

// Тип сервера, определяющий транспорт запроса.

DWORD dwServerType;

// Флаги.

ULONG64 ullFlags;

union

{

// Шаблон для DoH.

PWSTR pwszTemplate;

// Имя узла.

PWSTR pwszHostname;

};

// Хранилище SOCKADDR_INET.

char MaxSa[DNS_ADDR_MAX_SOCKADDR_LENGTH];

} DNS_CUSTOM_SERVER;

В структуре DNS_APPLICATION_SETTINGS поле опций может принимать одно значение — DNS_APP_SETTINGS_EXCLUSIVE_SERVERS, которое говорит о том, что необходимо использовать только DNS-серверы приложения, а не системные.

Тип сервера может быть следующим:

• DNS_CUSTOM_SERVER_TYPE_UDP — «обычный» DNS с выполнением запросов по UDP, то есть с использованием небезопасного канала передачи запроса.

• DNS_CUSTOM_SERVER_TYPE_DOH — сервер типа DoH. В этом случае запросы выполняются по HTTPS, что повышает безопасность.

В поле ullFlags структуры DNS_CUSTOM_SERVER определен единственный флаг — DNS_CUSTOM_SERVER_UDP_FALLBACK. Он говорит о том, что сервер может вернуться к небезопасному каналу в случае отказа DoH.

Освобождение ресурсов

Функции ниже используются, чтобы освободить память, выделенную предыдущими вызовами:

#include <windns.h>

typedef enum

{

// Освобождаемые данные являются плоской структурой.

DnsFreeFlat = 0,

// Освобождаемые данные — список записей ресурсов,

// который включает подполя DNS_RECORD.

DnsFreeRecordList,

// Освобождаемые данные — разобранное поле сообщения.

DnsFreeParsedMessageFields

} DNS_FREE_TYPE;

// Освободить память, выделенную функцией DnsQuery() для полученных

// записей DNS.

void DnsFree(PVOID pData, DNS_FREE_TYPE FreeType);

// Освободить память, выделенную для структуры DNS_QUERY_RAW_RESULT,

// которая используется при вызове DnsQueryRaw().

void DnsQueryRawResultFree(DNS_QUERY_RAW_RESULT *queryResults);

// Освободить память, выделенную функцией DnsQuery() для записей DNS.

void DnsRecordListFree(PDNS_RECORD pRecordList, DNS_FREE_TYPE FreeType);

DnsRecordListFree() в новых версиях ОС Windows представляет собой не функцию, а макрос, вызывающий DnsFree():

#if (_WIN32_WINNT >= 0x0501)

#define DnsRecordListFree(p,t) DnsFree(p, DnsFreeRecordList)

...

Память, которая была выделена функциями для работы с прокси, освобождается с помощью отдельного набора функций:

// Освободить память, выделенную для поля proxyName в экземпляре структуры

// DNS_PROXY_INFORMATION, выделенной функцией DnsGetProxyInformation().

void DnsFreeProxyName(PWSTR proxyName);

// Освободить массив пользовательских серверов,

// возвращаемый DnsGetApplicationSettings().

void DnsFreeCustomServers(DWORD *pcServers, DNS_CUSTOM_SERVER **ppServers);

Параметры последней функции:

• pcServers — указатель на количество серверов в массиве ppServers. После вызова функции будет равен 0.

• ppServers — указатель на массив DNS_CUSTOM_SERVER, содержащий элементы pcServers. После вызова функции сюда тоже будет записан 0.

Получение настроек DNS-интерфейса

Более подробно работу с интерфейсами мы изучим позже, а сейчас разберем функции IP Helper API для настройки DNS-интерфейсов:

#include <netioapi.h>

// Получить настройки DNS.

NETIO_STATUS GetInterfaceDnsSettings(GUID Interface,

DNS_INTERFACE_SETTINGS *Settings);

// Установить настройки DNS.

NETIO_STATUS SetInterfaceDnsSettings(GUID Interface,

const DNS_INTERFACE_SETTINGS *Settings);

Параметры функций GetInterfaceDnsSettings() и SetInterfaceDnsSettings():

• Interface — GUID интерфейса, для которого требуется получить или установить настройки.

• Settings — структура, которая содержит настройки или в которую они будут записаны.

Возвращаемые значения — NO_ERROR в случае успеха или код ошибки.

Функции работают со структурой DNS_INTERFACE_SETTINGS, причем функция GetInterfaceDnsSettings() сама выделяет память:

#include <netioapi.h>

typedef struct _DNS_INTERFACE_SETTINGS

{

// Версия структуры. Должна быть DNS_INTERFACE_SETTINGS_VERSION1.

ULONG Version;

// Битовые флаги.

ULONG64 Flags;

// Доменное имя адаптера.

PWSTR Domain;

// Адреса серверов имен, разделенные запятыми или пробелами.

PWSTR NameServer;

// Имена поиска, разделенные так же, как адреса DNS-серверов.

PWSTR SearchList;

// Истина, если динамическая регистрация адаптера включена.

ULONG RegistrationEnabled;

// Истина, если регистрация адаптера включена.

ULONG RegisterAdapterName;

// Включение mDNS и LLMNR — Link-Local Multicast Name Resolution.

ULONG EnableLLMNR;

// Истина, если имя адаптера должно использоваться как суффикс поиска.

ULONG QueryAdapterName;

// Список DNS-серверов.

PWSTR ProfileNameServer;

} DNS_INTERFACE_SETTINGS;

Поле версии при вызове функции определяет тип передаваемой структуры и может иметь следующие значения:

• DNS_INTERFACE_SETTINGS_VERSION1 — для структуры выше.

• DNS_INTERFACE_SETTINGS_VERSION2 — должна быть передана расширенная структура DNS_INTERFACE_SETTINGS_EX, которая включает DNS_INTERFACE_SETTINGS.

• DNS_INTERFACE_SETTINGS_VERSION3 — передается структура DNS_INTERFACE_SETTINGS3, аналогичная DNS_INTERFACE_SETTINGS, но содержащая набор свойств DNS-сервера, список имен для поиска и т.п.

Структура для расширенных параметров:

typedef struct _DNS_INTERFACE_SETTINGS_EX

{

DNS_INTERFACE_SETTINGS SettingsV1;

// Зарезервированное поле.

ULONG DisableUnconstrainedQueries;

//  Имена поиска, разделенные запятыми или пробелами.

PWSTR SupplementalSearchList;

} DNS_INTERFACE_SETTINGS_EX;

Значения флагов следующие:

• DNS_SETTING_IPV6 — настроить параметры интерфейса только для сетевого стека IPv6. Если этот параметр задан, все IP-адреса в атрибутах NameServer и ProfileNameServer должны быть IPv6-адресами.

• DNS_SETTING_NAMESERVER — настроить DNS-серверы статического адаптера на указанном интерфейсе через атрибут NameServer.

• DNS_SETTING_SEARCHLIST — настроить список поиска DNS-суффиксов для конкретного подключения с помощью атрибута SearchList.

• DNS_SETTING_REGISTRATION_ENABLED — включить динамическую регистрацию DNS для заданного адаптера. По умолчанию в системе включена.

• DNS_SETTING_DOMAIN — установить DNS-суффикс подключения для заданного адаптера из атрибута Domain.

• DNS_SETTINGS_ENABLE_LLMNR — включить разрешение имен с помощью LLMNR и mDNS на указанном адаптере. По умолчанию в системе включено.

• DNS_SETTINGS_QUERY_ADAPTER_NAME — включить использование имени адаптера в качестве суффикса для запросов DNS. По умолчанию в системе включено.

• DNS_SETTING_PROFILE_NAMESERVER — настроить DNS-серверы статического профиля на интерфейсе, то есть «обычные» DNS-серверы, с помощью атрибута ProfileNameServer.

Выделенную память необходимо удалить, используя следующую функцию:

#include <netioapi.h>

void FreeInterfaceDnsSettings(DNS_INTERFACE_SETTINGS *Settings);

Информацию о том, как использовать API, а также что они позволяют изменить, можно понять из назначения полей структуры DNS_INTERFACE_SETTINGS.

Прочие функции

Для получения имени узла WinSock предоставляет функции gethostname() и GetHostNameW():

#include <winsock2.h>

int WSAAPI gethostname(char FAR *name, int namelen);

int WSAAPI GetHostNameW(PWSTR name, int namelen);

Функции для установки имени узла, а также получения его идентификатора в WinSock отсутствуют. Но имя узла можно установить, вызвав функцию SetComputerName() из группы API системной информации:

#include <sysinfoapi.h>

// Установить имя узла.

bool SetComputerName(LPCTSTR lpComputerName);

// Получить имя узла.

bool GetComputerName(LPTSTR lpBuffer, LPDWORD nSize);

Эти функции устанавливают и получают NetBIOS-имя.

Для работы с DNS-именами локального узла существует еще один набор функций:

#include <sysinfoapi.h>

// Тип имени для установки или получения.

typedef enum _COMPUTER_NAME_FORMAT

{

ComputerNameNetBIOS,

ComputerNameDnsHostname,

ComputerNameDnsDomain,

ComputerNameDnsFullyQualified,

ComputerNamePhysicalNetBIOS,

ComputerNamePhysicalDnsHostname,

ComputerNamePhysicalDnsDomain,

ComputerNamePhysicalDnsFullyQualified,

ComputerNameMax

} COMPUTER_NAME_FORMAT;

// Установить имя.

bool SetComputerNameEx(

COMPUTER_NAME_FORMAT NameType,

LPCTSTR lpBuffer

);

// Получить имя.

bool GetComputerNameEx(

COMPUTER_NAME_FORMAT NameType,

LPSTR lpBuffer,

LPDWORD nSize

);

Параметры функций SetComputerNameEx() и GetComputerNameEx():

• NameType — входной параметр, задающий тип устанавливаемого или получаемого имени, указанный в перечислении COMPUTER_NAME_FORMAT.

• lpBuffer — входной или выходной буфер.

• nSize — размер в символах буфера, в который должно быть записано имя. Если размер недостаточен, в этот параметр будет записан необходимый размер.

Функции возвращают истинное значение в случае успеха.

DNS-имя можно преобразовать в NetBIOS-совместимое имя, вызвав следующую функцию:

#include <winbase.h>

bool DnsHostnameToComputerName(

LPCTSTR Hostname,

LPTSTR ComputerName,

LPDWORD nSize

);

Эта функция почти не используется, поэтому мы не будем разбирать ее подробно. При желании это можно сделать, обратившись к MSDN.

Для преобразования имен объектов службы каталогов существует функция TranslateName(), которая может работать и с DNS-именами. Но службу каталогов мы в книге не рассматриваем и этой функции касаться тоже не будем.

Для получения структур описания протоколов существуют POSIX-совместимые функции:

struct protoent

{

// Официальное имя протокола.

char *p_name;

// Список псевдонимов. Завершается нулем.

char **p_aliases;

// Номер протокола в хостовом порядке байтов.

short p_proto;

} PROTOENT, *PPROTOENT, *LPPROTOENT;

protoent *WSAAPI getprotobynumber(int number);

protoent *WSAAPI getprotobyname(const char *name);

Напомним, что данные функции являются устаревшими и лучше использовать getaddrinfo().

Функции для получения описания служб:

struct servent

{

// Имя службы.

char FAR * s_name;

// Альтернативные имена, массив завершается нулем.

char FAR * FAR * s_aliases;

#ifdef _WIN64

// Имя протокола.

char FAR * s_proto;

// Порт.

short s_port;

#else

short s_port;

char FAR * s_proto;

#endif

} SERVENT, *PSERVENT, *LPSERVENT;

servent *WSAAPI getservbyport(int port, const char *proto);

servent *WSAAPI getservbyname(const char *name, const char *proto);

Их описание приведено в главе 3. Видим, что структура servent несколько отличается от POSIX-варианта.

Для получения информации о точках подключения — адреса локального и удаленного абонентов — WinSock предоставляет POSIX-совместимые функции:

int WSAAPI getsockname(SOCKET s, sockaddr *name, int *namelen);

int WSAAPI getpeername(SOCKET s, sockaddr *name, int *namelen);

Они были подробно описаны в главе 5.

Резюме

В этой главе мы рассмотрели основные функции и структуры, предоставляемые WinSock для управления DNS, а также функции для работы с адресами IPv4 и IPv6.

Структуры адресов IPv4 и IPv6 аналогичны тем, что используются в POSIX-сокетах. WinSock предоставляет совместимые с POSIX функции для работы с ними. Например, получение списка адресов с помощью DNS осуществляется при помощи функции getaddrinfo(), а для обратного преобразования используется функция getnameinfo(), — их описание приведено в главе 2.

Для описанных операций существуют также Windows-реализации. Пример — WSAStringToAddress() и WSAAddressToString(). Таких функций достаточно много. Rtl-функции, помимо работы с IP-адресами, позволяют выполнять преобразования MAC-адресов.

В ОС Windows также существуют базы узлов и служб. Для навигации по базе служб используются функции WSALookupServiceBegin(), WSALookupServiceNext(), WSALookupServiceEnd().

Зарегистрировать новую службу в базе либо удалить из нее существующую возможно с помощью функции WSASetService().

Службы могут принадлежать некоторому классу, описывающему пространства имен, в которых будет зарегистрирована служба. Этот класс может задавать некоторые характеристики службы. Для работы с классами используются функции WSAGetServiceClassInfo(), WSAInstallServiceClass() и WSARemoveServiceClass().

Для работы с DNS существует Windows-специфичный набор функций, включая функции в IP Helper API для настройки DNS на интерфейсах. DNS можно настроить на конкретных интерфейсах либо даже специально для отдельных приложений. Использование этих функций позволит реализовать достаточно сложные сценарии адресации.

Вопросы и задания

1. Существуют ли отличия структур адресов от POSIX-совместимых аналогов? Если да, то какие?

2. Зачем нужна структура CSADDR_INFO?

3. Можно ли использовать POSIX-совместимые функции для работы с IPv6-адресами в WinSock? Если да, какие примеры таких функций можно привести?

4. В чем чаще всего проявляются отличия функций с суффиксом Ex от функций с теми же именами, но без суффикса? Объясните на примере GetAddrInfoEx() и GetAddrInfo().

5. Какие семейства адресов поддерживает функция inet_ntop() в WinSock?

6. В чем отличия функции WSAAddressToString() от inet_ntop()?

7. В чем преимущества и недостатки Rtl-функций, таких как RtlIpv4StringToAddress()? Какие проблемы могут возникнуть при их использовании?

8. Как сконвертировать маску подсети в длину префикса?

9. Как преобразовать IDN в ANSI-строку?

10. В чем отличие функции getaddrinfo() в WinSock от ее POSIX-аналога?

11. Для чего нужна база служб в ОС Windows?

12. Как найти службу в базе по имени?

13. Какие особенности имеются у API DNS-резолвера?

14. Каким образом можно определить TTL полученной записи DNS?

15. Как настроить прокси-сервер для конкретного приложения?

16. Как получить и установить имя узла?

17. В чем отличие структуры servent в WinSock от ее POSIX-версии?

18. Как получить информацию о локальной точке подключения с помощью WinSock API?

19. Напишите программу, которая в качестве входного параметра будет принимать IP-адрес и выводить имена узлов, зарегистрированных в DNS для этого адреса.

Глава 16. Сокетный API в ОС Windows

Куда вы хотите пойти сегодня?

Слоган Microsoft в 1994–2002 гг.

Введение

В этой главе мы перейдем к рассмотрению, вероятно, наиболее важной для сетевой разработки в ОС Windows темы — ее сокетного API.

По аналогии с предыдущими главами книги рассмотрим, какие типы сокетов поддерживаются в ОС Windows, на примерах разберем процесс их создания, а также обмен данными без установки соединения, его завершение и работу с использованием соединения со стороны как сервера, так и клиента.

Кроме того, разберемся, чем отличается работа с внеполосными данными. Коснемся обработки возможных ошибок, а также начнем рассмотрение использования не только блокирующих, но и неблокирующих операций ввода-вывода, но продолжим изучать эту тему в следующих книгах.

Затем изучим специфические особенности работы с WinSock и некоторые различия между сокетными вызовами Windows и POSIX — чтобы проще реализовывать кросс-платформенные приложения.

Помимо этого мы начнем рассмотрение более сложной темы — провайдеров сокетов. Узнаем, как работать с провайдерами транспорта. Эта информация нужна для лучшего понимания работы сетевой подсистемы в ОС Windows и может быть полезна разработчикам для создания сложных приложений, которые могут осуществлять обработку любых данных протокола требуемым способом, при этом сохраняя интерфейс сокетов.

Также в этой главе мы немного затронем вопрос реализации провайдеров имен.

Основы WinSock API

Перейдем к основам работы с сокетами в ОС Windows. Начнем с создания нового сокета и привязки адреса к нему.

Рассмотрим, как POSIX-совместимый API, так и специфичный для ОС Windows.

Создание сокета

Хотя в ОС Windows сокет — это не файловый дескриптор, он, как и тип дескриптора, является непрозрачным, то есть приложения могут обращаться к нему только через функции WinSock API.

Для сокетов это отдельный тип SOCKET, определенный в winsock2.h. В WinSock для создания сокета можно использовать две функции: socket() и WSASocket().

Функция socket() POSIX-совместима, ведет себя как было описано ранее и имеет тот же интерфейс, что и функция socket() в Linux:

SOCKET WSAAPI socket(int af, int type, int protocol);

Параметры функции socket():

• af — семейство адресов протокола. Аналогично таковому в POSIX:

• AF_INET или AF_INET6 — для интернет-сокетов.

• AF_UNIX — для Unix-сокетов.

• AF_UNSPEC — можно задать автоматический выбор семейства по значению протокола, но делать это не рекомендуется.

• type — тип сокета:

• SOCK_STREAM — для потоковых сокетов, например TCP.

• SOCK_DGRAM — для сокетов, ориентированных на сообщения.

• SOCK_RAW — для сырых сокетов, которые полноценно поддерживаются не во всех версиях ОС Windows.

• SOCK_RDM — надежная передача дейтаграмм. Используется для PGM.

• SOCK_SEQPACKET — последовательность пакетов, сохраняющих границы или поток дейтаграмм.

• protocol — протокол. Зависит от семейства адресов и провайдера сокетов. Например, для Bluetooth определен протокол BTHPROTO_RFCOMM. Мы рассмотрим только протоколы Internet:

• IPPROTO_ICMP — «обычный» ICMP. Подходит для семейств AF_UNSPEC, AF_INET, AF_INET6 и типа сокета SOCK_RAW либо не заданного.

• IPPROTO_IGMP — протокол управления группами. Семейства и тип сокетов как для ICMP.

• IPPROTO_ICMPV6 — ICMP для IPv6. Семейства и тип сокетов как для ICMP.

• IPPROTO_TCP — семейства AF_INET и AF_INET6, тип — SOCK_STREAM.

• IPPROTO_UDP — семейства AF_INET и AF_INET6, тип — SOCK_DGRAM.

Подробное описание функции см. в главе 1, а для конкретных типов сокетов — в других предыдущих главах.

Функция WSASocket() — ее расширенный вариант в Windows:

SOCKET WSAAPI WSASocket(

int af,

int type,

int protocol,

LPWSAPROTOCOL_INFO lpProtocolInfo,

GROUP g,

DWORD dwFlags

);

Она имеет три дополнительных параметра:

• lpProtocolInfo — указатель на структуру WSAPROTOCOL_INFO, в которой хранятся характеристики нового сокета. Если этот параметр ненулевой, созданный сокет будет привязан к описанному этой структурой провайдеру. Используется при создании нового дескриптора сокета функцией WSADuplicateSocket(). Чтобы определить, какие параметры должны быть взяты от существующего экземпляра сокета, необходимо подставить на место семейства адресов, типа или протокола константу FROM_PROTOCOL_INFO.

• g — это идентификатор существующей группы сокетов:

• 0 — группы не используются.

• 0x01 или SG_UNCONSTRAINED_GROUP — создать неограниченную группу сокетов и сделать новый сокет первым ее членом.

• 0x02 или SG_CONSTRAINED_GROUP — создать ограниченную группу сокетов и сделать новый сокет первым членом.

• В ином случае — идентификатор группы, в которую будет включен сокет.

• dwFlags — набор флагов, используемых для указания дополнительных атрибутов сокета:

• WSA_FLAG_OVERLAPPED — сокет будет поддерживать перекрывающиеся операции ввода-вывода. Перекрывающийся ввод-вывод позволяет выполнять несколько операций одновременно. Он будет рассмотрен в книге 2.

• WSA_FLAG_MULTIPOINT_C_ROOT, WSA_FLAG_MULTIPOINT_C_LEAF, WSA_FLAG_MULTIPOINT_D_ROOT, WSA_FLAG_MULTIPOINT_D_LEAF — данные флаги задают тип сокета в сеансе многоточечной рассылки: c_root, c_leaf, d_root, d_leaf — если многоадресная рассылка поддерживается транспортным провайдером.

• WSA_FLAG_ACCESS_SYSTEM_SECURITY — разрешить установку дескриптора безопасности сокета. В этом случае для сокета можно установить список правил доступа SACL, по которым будут создаваться исключения и предупреждения с записью в журнал безопасности при нарушении прав доступа. Может быть установлен, если у вызывающего есть право ACCESS_SYSTEM_SECURITY.

• WSA_FLAG_NO_HANDLE_INHERIT — сокет не будет наследоваться дочерними процессами.

Основное различие между этими функциями в том, что WSASocket() позволяет задать дополнительные флаги и указатель на структуру провайдера, который будет обслуживать сокет.

Если ошибки не возникает, функции возвращают дескриптор, ссылающийся на сокет. В противном случае возвращается значение INVALID_SOCKET.

Пример вызова:

auto sock = WSASocket(AF_INET, SOCK_RAW, IPPROTO_ICMP, nullptr, 0,

WSA_FLAG_OVERLAPPED);

if (INVALID_SOCKET == sock)

{

// Ошибка.

}

Группа сокета

При создании сокета можно управлять его группой, атрибутами безопасности и флагом наследования.

Группы сокетов требуются для того, чтобы задать входящим сокетам атрибуты, например относительные приоритеты сокетов в группе или спецификацию QoS для группы.

Ограниченная группа сокетов может состоять только из сокетов, ориентированных на соединение, и требует, чтобы все сокеты имели одинаковые значения типа и протокола, а соединения всех сокетов группы устанавливались с одинаковым адресом.

По умолчанию идентификатор группы равен 0, то есть сокет не принадлежит никакой группе.

Идентификаторы существующих групп сокетов уникальны для всех процессов данного провайдера транспорта. Группа и связанный идентификатор существуют, пока не закрыт последний сокет в группе.

Когда группа создана, ее идентификатор можно получить с помощью функции getsockopt():

SOCKET sock = WSASocket(...);

GROUP group_id = 0;

socklen_t gid_size = sizeof(group_id);

if (getsockopt(sock, SOL_SOCKET, SO_GROUP_ID, &group_id, &gid_size) != 0)

{

// Ошибка.

}

Точно так же получить или установить групповой приоритет можно, используя опцию SO_GROUP_PRIORITY уровня SOL_SOCKET. Как он будет использоваться, зависит от провайдера транспорта.

Константы SG_UNCONSTRAINED_GROUP = 0x01 и SG_CONSTRAINED_GROUP = 0x02 в настоящее время не определены в общедоступном заголовочном файле, поэтому и указаны числа.

Привязка адреса

После создания сокета адрес привязывается с помощью функции bind():

#include <winsock2.h>

int WSAAPI bind(SOCKET s, const sockaddr *name, int namelen);

Параметры функции bind():

• s — дескриптор сокета.

• name — адрес для связывания. Например, IP-адрес, порт и протокол.

• namelen — это просто размер передаваемой структуры адреса в байтах.

В случае ошибки bind() возвращает SOCKET_ERROR, в случае успеха — 0.

Значимых отличий между POSIX- и WinSock2-версиями функций bind() нет. Передача адреса типа in6addr_any либо INADDR_ANY приведет к тому, что для подключений будут использоваться все сетевые адаптеры.

Если порт нулевой, провайдер транспорта установит случайный неиспользуемый порт, выбирая его так же, как эфемерный.

Максимальное значение порта задается в ключе реестра HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\MaxUserPort.

Диапазон значений портов, начиная с Windows Vista, составляет 49152-65535.

Ранее — 1025-5000.

Unix-сокеты

Начиная с версии Windows 10 Insider build 17063, в ОС Windows появились сокеты UNIX-domain, описанные в главе 3. Их наличие позволяет создавать кросс-платформенный межпроцессный API. Windows 11 поддерживает такие сокеты по умолчанию.

Проверить их поддержку можно следующим образом:

C:\> sc query afunix

SERVICE_NAME: afunix

TYPE               : 1  KERNEL_DRIVER

STATE              : 4  RUNNING

(STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)

WIN32_EXIT_CODE    : 0  (0x0)

SERVICE_EXIT_CODE  : 0  (0x0)

CHECKPOINT         : 0x0

WAIT_HINT          : 0x0

Внимание! Такая проверка может потребовать административных прав.

Большая часть поддержки Unix-сокетов реализуется в ядре драйвером afunix.sys. Выше мы видим, что драйвер загружен и работает.

Сам файл сокета является пользовательской точкой повторной обработки NTFS.

Для определения адреса Unix-сокета используется структура sockaddr_un. В реализации для Windows имя, семантика и определение адреса Unix-сокета такие же, как в Linux.

Существует три разных формата адресации для сокетов Unix:

• Привязанные к файловой системе. Элемент структуры sun_path содержит путь в файловой системе. Кодировка символов — UTF-8. Путь оканчивается нулем.

• Абстрактные. Первый символ в sun_path — 0. Реализация не поддерживает функцию автоматической привязки, то есть такую, при которой абстрактный адрес генерируется автоматически.

• Безымянные. Сокет привязан к пути без имени, например это сокеты, которые могут быть созданы функцией socketpair(). Хотя данный тип сокета и поддерживается драйвером, API WinSock его не поддерживает.

Когда сокет связан с действительным путем, в файловой системе создается файл сокета. Приложение должно разорвать связь, прежде чем к тому же адресу может быть привязан другой сокет. Перед повторным использованием файл сокета необходимо удалить.

Передача вспомогательных данных, например файловых дескрипторов через SCM_RIGHTS или учетных данных через SCM_CREDENTIALS, не поддерживается.

Передать дескрипторы можно, используя функцию WSADuplicateSocket(), которая описана в разделе «Управление дескриптором» главы 18.

Также не поддерживается функция socketpair().

Для работы с Unix-сокетами в WinSock2 требуется подключить соответствующий заголовочный файл:

#include <afunix.h>

Тип сокета необходимо установить в SOCK_DGRAM или SOCK_SEQPACKET. В остальном работа с ними ведется так же, как с любым другим типом сокетов.

Так же как в Unix-подобных системах, данные сокеты помогают установить безопасную связь. Ограничить круг процессов, которые могут использовать сокет, можно, задав права доступа к файлу сокета или каталогу, в котором он находится.

Raw-сокеты

Raw-сокеты позволяют манипулировать базовым транспортом, и в Microsoft посчитали, что их наличие создаст проблемы с безопасностью. Поэтому только члены группы администраторов могут использовать сокеты типа SOCK_RAW.

Начиная с Windows Vista, не имея прав, создать такой сокет не получится, а в более ранних версиях пользователи, не являющиеся администраторами, при работе с таким сокетом будут получать от bind() ошибку WSAEACCES. Чтобы обойти это ограничение в Windows, можно отключить проверку, создав следующую переменную реестра типа DWORD со значением 1:

HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\Afd\Parameters

\DisableRawSecurity

После этого raw-сокет возможно использовать так же, как в Linux. Особенностью raw-сокетов является то, что при их создании IP-заголовок будет включен в возвращаемые при приеме данные, независимо от того, установлена ли опция IP_HDRINCL.

Приложение должно само определить длину IP-заголовка, чтобы найти полезную нагрузку в буфере.

Кроме того, raw-сокеты, начиная с Windows XP, имеют дополнительный набор ограничений, введенный в целях безопасности:

• Запрет на использование TCP поверх raw-сокетов. Для raw-сокета типа IPPROTO_TCP не получится вызвать bind(), хотя для сокетов типа IPPROTO_IP, IPPROTO_UDP или IPPROTO_SCTP это возможно.

• Запрет на отправку UDP-дейтаграмм с недопустимым исходным адресом. Исходный IP-адрес любой исходящей UDP-дейтаграммы должен существовать для интерфейса, через который производится отправка, иначе дейтаграмма удаляется.

Зачастую из-за этих ограничений разработчики снифферов и сканеров реализуют свои драйверы или свой провайдер транспорта.

Пример работы с raw-сокетами вы можете посмотреть в главе 4, изучив код утилиты ping. Она кросс-платформенная и будет работать в ОС Windows.

Модели ввода-вывода в WinSock

В WinSock доступны следующие типы ввода-вывода для сокетов:

• Блокирующий. Вызов не вернет управление до получения или отправки данных.

• Неблокирующий, или асинхронный. Сначала отправляется запрос к ядру, после чего поток выполняется далее.

Для неблокирующего ввода-вывода существует несколько вариантов:

• С использованием select() и WSAPoll(). Выполняется периодический опрос дескрипторов на готовность.

• Перекрывающийся, или overlapped. По завершении исполнения вызова активируется событие либо отправляется сообщение в порт завершения ввода-вывода.

• APC — асинхронный вызов процедур. По завершении операции будет вызвана процедура, указатель на которую передавался функции при вызове.

• Оконные сообщения. При наступлении события WinSock отправляет сообщения в окно с указанным хэндлом. Эти сообщения можно читать и фильтровать с помощью функции WSAAsyncSelect(), вызов которой также переводит сокет в неблокирующий режим. Эта опция устарела, и вместо нее Microsoft предлагает использовать перекрывающийся, или overlapped, ввод-вывод.

• Уведомления через объекты событий. Функция WSAEventSelect() позволяет установить события на дескриптор сокета. Эти события активируются при наступлении определенных условий, например завершении чтения.

Сокетный API, как правило, не использует APC. В случае WinSock функция WSAAccept() использует процедуру обратного вызова для сигнализации о завершении.

Пример функции, использующей APC, — SetWaitableTimer(). Также с помощью APC реализуются функции ReadFileEx() и WriteFileEx().

Следует помнить, что приложения Windows основаны на асинхронном фреймворке. Этот фреймворк — оконная система и «оконная функция», в которой выполняется цикл обработки сообщений окна. Работа с этим циклом — естественный для Windows сценарий, то есть «основной режим» WinAPI — асинхронный.

Блокирующий режим используется только в самых простых малонагруженных приложениях, а также для создания прототипов будущих приложений. Использующие блокирующий режим приложения хуже масштабируются, часто создают для обработки ввода-вывода один или два потока на соединение и потребляют лишнее процессорное время на переключение между потоками. Однако блокирующий режим естественен и прост, и поэтому он использовался в предыдущих главах и будет использоваться в книге и далее. Неблокирующий ввод-вывод значительно сложнее, поэтому к нему мы перейдем только в книге 2.

Связь без установки соединения

В главе 3 мы уже рассмотрели протокол UDP и API-сокетов, поддерживающий работу по протоколам без установки соединения.

В ОС Windows существует как POSIX-совместимый вариант API, так и своя реализация для работы с такими сокетами. Прототип и поведение совместимых функций аналогичны описанным ранее. Используя эти функции, после создания нового сокета и привязки его к интерфейсу через bind() уже можно осуществлять обмен данными.

Поскольку соединение отсутствует, принимающий сокет может получать дейтаграммы, исходящие от любой машины в сети.

Специфичная для ОС Windows функция приема дейтаграмм — WSARecvFrom(). Она отличается только возможностью производить асинхронный ввод-вывод:

#include <winsock2.h>

int WSAAPI recvfrom(

SOCKET s,

char *buf,

int len,

int flags,

sockaddr *from,

int *fromlen

);

int WSAAPI WSARecvFrom(

SOCKET s,

LPWSABUF lpBuffers,

DWORD dwBufferCount,

LPDWORD lpNumberOfBytesRecvd,

LPDWORD lpFlags,

sockaddr *lpFrom,

LPINT lpFromlen,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Параметры, отсутствующие у совместимой функции:

• lpOverlapped — необязательный указатель на структуру WSAOVERLAPPED.

• lpCompletionRoutine — указатель на функцию завершения, вызываемую после завершения операции приема.

Эти параметры игнорируются для сокетов, не использующих перекрывающийся ввод-вывод.

Из флагов приема данных поддерживаются следующие:

• MSG_PEEK — не удалять данные из буфера.

• MSG_OOB — принимать внеполосные данные.

• MSG_PARTIAL — указывает, что буфер содержит только часть сообщения.

Для отправки данных предназначена совместимая функция sendto() и специфичная для ОС Windows WSASendTo():

#include <winsock2.h>

int sendto(

SOCKET s,

const char *buf,

int len,

int flags,

const sockaddr *to,

int tolen

);

int WSAAPI WSASendTo(

SOCKET s,

LPWSABUF lpBuffers,

DWORD dwBufferCount,

LPDWORD lpNumberOfBytesSent,

DWORD dwFlags,

const sockaddr *lpTo,

int iTolen,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Различие между ними то же: возможность использовать перекрывающийся ввод-вывод.

Так же как в POSIX-варианте, поддерживаются флаги MSG_DONTROUTE, MSG_OOB и MSG_PARTIAL. Однако флаг MSG_DONTROUTE игнорируется провайдером от Microsoft. Вместо него можно использовать ioctl SIO_ASSOCIATE_HANDLE.

Внимание! Флаги могут не только управлять поведением функций, но и быть установлены функциями после вызова как индикаторы. Таким является флаг MSG_PARTIAL.

Флаг MSG_PARTIAL специфичен для ОС Windows и предназначен только для сокетов, ориентированных на сообщения.

В функциях приема этот флаг говорит о том, что получено частичное сообщение. Остальные части сообщения будут получены следующими вызовами функций приема.

Принимать такое сообщение необходимо до первой операции, в которой MSG_PARTIAL не установлен, то есть принят конец сообщения.

Для функций передачи флаг указывает, что lpBuffers содержит только частичное сообщение. Транспортами, которые не поддерживают передачу частичных сообщений, будет возвращаться код ошибки WSAEOPNOTSUPP.

В случае отложенного завершения значение, на которое указывает lpFlags, не обновляется. Приложение должно вызвать функцию WSAGetOverlappedResult() и проверить флаги, на которые указывает параметр lpdwFlags.

Возвращаемые значения функций различны:

• WSARecvFrom() и WSASendTo() возвращают 0 в случае успеха, значение SOCKET_ERROR при ошибке и WSA_IO_PENDING, если запущена перекрывающаяся операция.

• recvfrom() и sendto() — количество принятых или переданных данных соответственно.

Связь, ориентированная на соединение

Так же как в случае связи без установки соединения, ОС Windows поддерживает совместимые функции и имеет специфичные.

На рис. 16.1 показано соответствие между процессами работы клиента и сервера, а также соответствие функций WinSock 2 и POSIX-совместимых.

Рис. 16.1. Процессы работы клиента и сервера в WinSock

Функции клиента

Клиент намного проще, чем сервер, и требует меньше шагов для установки успешного подключения. Его API состоит только из функции подключения.

Совместимая функция connect() имеет прототип, похожий на тот, который присутствует в Linux:

#include <winsock2.h>

int WSAAPI connect(SOCKET s, const sockaddr *name, int namelen);

Параметры функции connect():

• s — дескриптор сокета;

• name — указатель на структуру адреса;

• namelen — размер адреса.

Разница в том, что длина имени имеет тип int, а не size_t. Возвращаемое значение — 0 в случае успеха или SOCKET_ERROR в случае неудачи.

Функция GetLastError() вернет WSAECONNREFUSED, если отсутствует процесс, прослушивающий данный порт. Если же сервер недоступен, будет возвращено WSAETIMEDOUT. Полный список ошибок см. в MSDN.

Функция WSAConnect() имеет гораздо больше возможностей, чем совместимая функция connect():

#include <qos.h>

#include <winsock2.h>

int WSAAPI WSAConnect(

SOCKET s,

const sockaddr *name,

int namelen,

LPWSABUF lpCallerData,

LPWSABUF lpCalleeData,

LPQOS lpSQOS,

LPQOS lpGQOS

);

Параметры, специфичные для функции WSAConnect():

• lpCallerData — указатель на пользовательские данные, которые должны быть переданы в сокет удаленного абонента при установлении соединения.

• lpCalleeData — указатель на пользовательские данные, которые должны быть получены из сокета удаленного абонента при установлении соединения.

• lpSQOS — указатель на структуру, которая содержит параметры QoS.

• lpGQOS — зарезервировано для будущего использования с группами сокетов. Если применимо, указатель на структуры QoS для группы сокетов. Этот параметр должен быть нулевым.

В случае успеха WSAConnect() вернет 0, в противном случае — SOCKET_ERROR. Коды ошибки такие же, как и для connect(), кроме специфичных для функции — WSAEOPNOTSUPP и WSAEPROTONOSUPPORT.

Параметры lpCallerData и lpCalleeData предназначены для указания на данные, которые будут переданы или получены при соединении соответственно. Если они имеют нулевое значение, пользовательские данные не передаются и не принимаются.

В lpCalleeData данные могут быть записаны, если они были переданы сервером на этапе подключения и операция подключения завершилась успешно.

Для неблокирующих сокетов о завершении данной операции говорит уведомление FD_CONNECT.

Атрибут len структуры WSABUF, на который указывает параметр lpCalleeData, должен содержать длину буфера, выделенного приложением. Если он равен 0, данные не будут переданы или не были получены.

Эту функцию имеет смысл использовать вместе с функцией WSAAccept(), которая описана далее.

Во время подключения приложение может использовать параметры lpSQOS и lpGQOS, чтобы переопределить предыдущие параметры качества обслуживания. Если параметр lpSQOS ненулевой, он должен содержать указатель на структуру, определяющую параметры QoS для обоих направлений. Если провайдер транспорта не может выполнить запрос на качество обслуживания, будет возвращена ошибка.

Для однонаправленных сокетов параметры QoS отправки или получения будут игнорироваться.

Параметр lpGQOS нужен, чтобы установить параметры QoS для группы, и действует, только если сокет является лидером группы сокетов.

Структуры, которые используются для работы с QoS:

#include <qos.h>

#include <ws2def.h>

typedef struct _flowspec

{

// Разрешенная скорость, с которой могут передаваться данные.

ULONG TokenRate;

// Максимальное количество байтов для данного направления потока,

// независимо от времени.

ULONG TokenBucketSize;

// Верхний разрешенный предел на передачу в байтах в секунду.

ULONG PeakBandwidth;

// Максимальное количество микросекунд между передачей и получением бита.

ULONG Latency;

// Количество микросекунд между максимальной и минимальной задержкой

// пакета.

ULONG DelayVariation;

// Уровень обслуживания для согласования потока:

// SERVICETYPE_NOTRAFFIC, SERVICETYPE_BESTEFFORT и прочие.

SERVICETYPE ServiceType;

// Максимальный разрешенный размер пакета в байтах.

ULONG MaxSduSize;

// Минимальный размер пакета, для которого обеспечивается

// качество обслуживания.

ULONG MinimumPolicedSize;

} FLOWSPEC, *PFLOWSPEC, *LPFLOWSPEC;

typedef struct _QualityOfService

{

// QoS для отправки.

FLOWSPEC SendingFlowspec;

// QoS для приема.

FLOWSPEC ReceivingFlowspec;

// Параметры, специфичные для провайдера.

WSABUF ProviderSpecific;

} QOS, *LPQOS;

Для приложений, которые будут работать только на версиях ОС Windows Vista и более поздних, Microsoft предлагает использовать более удобные функции: WSAConnectByName() и WSAConnectByList(). Первая из них позволяет выполнять подключение к указанной службе по имени узла и службы, при необходимости автоматически получая адресную информацию:

#include <winsock2.h>

bool WSAConnectByName(

SOCKET s,

LPTSTR nodename,

LPTSTR servicename,

LPDWORD LocalAddressLength,

LPSOCKADDR LocalAddress,

LPDWORD RemoteAddressLength,

LPSOCKADDR RemoteAddress,

const timeval *timeout,

LPWSAOVERLAPPED Reserved

);

Параметры функции WSAConnectByName():

• s — дескриптор сокета.

• nodename — строка, которая содержит имя, а также IPv4- или IPv6-адрес узла, к которому требуется подключиться.

• servicename — строка, которая содержит имя службы или порт узла для подключения.

• LocalAddressLength — указатель на размер буфера LocalAddress. Перед вызовом длина устанавливается равной размеру буфера. После успешного завершения функции сюда будет записан новый размер адреса.

• LocalAddress — указатель на структуру SOCKADDR, получающую адрес локального абонента. Если параметр нулевой, LocalAddressLength тоже игнорируется.

• RemoteAddressLength — указатель на размер буфера RemoteAddress в байтах. После успешного завершения вызова значение переменной будет переписано размером адреса, установленного в RemoteAddress.

• RemoteAddress — указатель на структуру SOCKADDR, которая получает адрес удаленного абонента.

• timeout — указатель на переменную, содержащую количество миллисекунд, в течение которых функция будет ожидать ответ от удаленного приложения перед прерыванием вызова.

• Reserved — параметр зарезервирован и должен быть установлен в nullptr.

В буфер локального адреса будет записано то же, что вернет функция getsockname(), а в буфер удаленного адреса — то же, что функция getpeername().

Вторая функция подключается к адресам из переданного списка. Процесс похож на то, что необходимо делать после вызова getaddrinfo() — перебирать адреса, пытаясь установить соединение. Но функция WSAConnectByList() также пытается установить соединение, используя адреса с наибольшим шансом на успех, что не только гарантирует, что соединение будет установлено, если оно вообще возможно, но и минимизирует время на его установление.

Эти адреса функция получает через вызов IP Helper API CreateSortedAddressPairs().

Рассмотрим прототип WSAConnectByList():

#include <winsock2.h>

typedef struct _SOCKET_ADDRESS_LIST

{

// Количество структур в массиве Address.

int iAddressCount;

// Массив адресов.

SOCKET_ADDRESS Address[1];

} SOCKET_ADDRESS_LIST, *PSOCKET_ADDRESS_LIST, FAR *LPSOCKET_ADDRESS_LIST;

bool WSAConnectByList(

SOCKET s,

PSOCKET_ADDRESS_LIST SocketAddress,

LPDWORD LocalAddressLength,

LPSOCKADDR LocalAddress,

LPDWORD RemoteAddressLength,

LPSOCKADDR RemoteAddress,

const timeval *timeout,

LPWSAOVERLAPPED Reserved

);

Параметры функции WSAConnectByList():

• s — дескриптор сокета.

• SocketAddress — указатель на структуру SOCKET_ADDRESS_LIST, которая представляет возможные адреса и порты для подключения к узлу.

• timeout — время в миллисекундах, в течение которого ожидается ответ от удаленного приложения перед прерыванием вызова.

Если тайм-аут имеет нулевое значение, WSAConnectByList() завершится либо после успешного установления соединения, либо после неудачной попытки подключения для всех возможных пар «локальный — удаленный адрес».

Параметры LocalAddressLength, LocalAddress, RemoteAddressLength, RemoteAddress и Reserved аналогичны таковым в функции WSAConnectByName().

Обе функции возвращают логическое значение — истину в случае успеха.

Функции сервера

Рассмотрим API, который предоставляет ОС Windows для реализации серверных приложений.

Функция listen()

Прототип функции listen() такой же, как и в Unix-подобных системах:

#include <winsock2.h>

int WSAAPI listen(SOCKET s, int backlog);

Параметры функции listen():

• s — дескриптор сокета;

• backlog — максимальное количество соединений, ожидающих в очереди.

Возвращаемое значение такое же, как и у POSIX-функции: 0 в случае успеха и SOCKET_ERROR при неудаче.

Работа с очередью соединений несколько отличается. Если очередь соединений заполнена, следующий запрос клиента вернет ошибку WSAECONNREFUSED.

Ограничения параметра backlog

Параметр backlog неявно ограничен значением, определяемым базовым провайдером. Недопустимые значения заменяются ближайшим допустимым.

Если установлено значение SOMAXCONN, провайдер транспорта установит размер очереди на «максимально разумное» значение.

Если установлено значение SOMAXCONN_HINT(N), значение backlog будет равно N с поправкой на диапазон (200, 65535), причем оно может быть больше, чем SOMAXCONN.

SOMAXCONN_HINT поддерживается только провайдером Microsoft TCP/IP.

Функции accept() и WSAAccept()

Запрос на соединение от клиента принимается с помощью функций accept() или WSAAccept().

Windows accept() почти аналогична POSIX-совместимой версии, но отличается типом параметра addrlen: в POSIX версии это socklen_t:

#include <winsock2.h>

SOCKET WSAAPI accept(SOCKET s, sockaddr *addr, int *addrlen);

Параметры функции accept():

• s — дескриптор сокета.

• addr — указатель на буфер с адресом подключающегося объекта, определяемый семейством адресов.

• addrlen — указатель на длину буфера с адресом.

Гораздо больший интерес представляет функция WSAAccept(), которая может работать как в асинхронном, так и в синхронном режиме:

#include <winsock2.h>

int CALLBACK ConditionFunc(

LPWSABUF lpCallerId,

LPWSABUF lpCallerData,

LPQOS lpSQOS,

LPQOS lpGQOS,

LPWSABUF lpCalleeId,

LPWSABUF lpCalleeData,

GROUP FAR *g,

DWORD_PTR dwCallbackData

);

SOCKET WSAAPI WSAAccept(

SOCKET s,

sockaddr *addr,

LPINT addrlen,

LPCONDITIONPROC lpfnCondition,

DWORD_PTR dwCallbackData

);

Параметры, специфичные для функции WSAAccept():

• lpfnCondition — адрес функции обратного вызова. Она может принимать или отклонять соединение.

• dwCallbackData — пользовательские данные, передаваемые функции lpfnCondition.

Обе функции возвращают дескриптор клиентского сокета в случае успеха или INVALID_SOCKET в случае неудачи. Помимо этого они записывают по указателю в addr адрес принятого клиента, а в addrlen — размер этого адреса, если эти указатели ненулевые. В остальном функция WSAAccept() будет работать так же, как и accept(), если не указан параметр lpfnCondition — условная функция. Она может вернуть следующие значения:

• CF_ACCEPT — функция WSAAccept() создаст новый сокет.

• CF_REJECT — соединение будет отклонено.

• CF_DEFER — принятие решения отложено, и никаких действий для этого соединения выполнено не будет.

Пока запрос на отложенное подключение находится в начале очереди отложенных задач, провайдер не выдает дополнительных указаний для ожидающих запросов на подключение.

Помимо отклонения или принятия нового подключения условная функция может не только прочитать, но и установить для сокета различные параметры, например задать группу сокета по указателю, который был ей передан:

• lpCallerId — буфер с адресом подключившегося клиента, в случае INET-сокетов — sockaddr.

• lpCallerData — данные, отправленные клиентом при его подключении.

• lpSQOS, lpGQOS — параметры QoS.

• lpCalleeId — локальный адрес.

• lpCalleeData — данные, которые сервер отправляет клиенту при его подключении.

• g — управление группой сокета. Параметр может принимать следующие значения:

• 0 — групповые операции не выполняются.

• Существующий идентификатор группы — сокет будет добавлен в группу.

• SG_UNCONSTRAINED_GROUP — создать неограниченную группу сокетов и сделать сокет первым членом.

• SG_CONSTRAINED_GROUP — создать ограниченную группу сокетов и сделать сокет первым членом.

• dwCallbackData — пользовательские данные, переданные как параметр WSAAccept().

Использование данной функции в приложениях вместе с данными, отправляемыми WSAConnect(), дает возможность быстро отфильтровать большое число подключений по пользовательскому критерию. Это может быть полезно для высоконагруженных приложений, о которых мы поговорим в книге 2.

Обмен данными

Функции recv() и send(), рассмотренные в главе 5, имеют POSIX-совместимый прототип:

#include <winsock2.h>

int recv(SOCKET s, char *buf, int len, int flags);

int WSAAPI send(SOCKET s, const char *buf, int len, int flags);

Параметр длины буфера имеет тип int, а не size_t, а также отличается тип возвращаемого значения.

Функция recv() поддерживает флаги MSG_PEEK, MSG_OOB, MSG_WAITALL, если их поддерживает провайдер. Функция send() поддерживает флаги MSG_DONTROUTE и MSG_OOB. Описание флагов см. в главах 4 и 8.

Обе функции возвращают количество отправленных байтов, 0 или –1 в случае ошибки.

Специфичные для ОС Windows функции дают возможность использовать перекрывающийся ввод-вывод и частичные дейтаграммы:

#include <winsock2.h>

int WSAAPI WSARecv(

SOCKET s,

LPWSABUF lpBuffers,

DWORD dwBufferCount,

LPDWORD lpNumberOfBytesRecvd,

LPDWORD lpFlags,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

int WSAAPI WSASend(

SOCKET s,

LPWSABUF lpBuffers,

DWORD dwBufferCount,

LPDWORD lpNumberOfBytesSent,

DWORD dwFlags,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Параметры, специфичные для функций в ОС Windows:

• lpBuffers — указатель на массив буферов, каждый из которых описывается структурой WSABUF, представленной в разделе «Общие структуры данных».

• dwBufferCount — количество буферов в массиве.

• lpNumberOfBytesRecvd и lpNumberOfBytesSent — указатели на количество полученных или отправленных байтов соответственно. Будут установлены, только если операции завершаются немедленно.

• lpFlags — указатель на флаги.

• lpOverlapped — указатель на структуру WSAOVERLAPPED. Используется для перекрывающегося ввода-вывода.

• lpCompletionRoutine — указатель на функцию завершения. Используется для перекрывающегося ввода-вывода.

Функции принимают и передают данные в наборе буферов. С точки зрения функциональности от POSIX-версий они отличаются возможностью использовать перекрывающийся ввод-вывод.

Функция WSASend() поддерживает дополнительный флаг MSG_PARTIAL, а WSARecv() — флаг MSG_PEEK для получения данных из буфера, не удаляя их, а также флаг MSG_PUSH_IMMEDIATE, который предназначен для потоковых сокетов. Он сообщает провайдеру транспорта, что не следует задерживать выполнение частично заполненных ожидающих запросов на получение данных. То есть приложение будет получать входящие данные как можно скорее, не дожидаясь данных, которые находятся в пути. Обе функции возвращают 0 в случае успеха и WSA_SOCKETERROR при ошибке. В остальном они ведут себя как WSARecvFrom() и WSASendTo() соответственно.

Завершение соединения

По окончании работы с сокетом можно завершить соединение и освободить все ресурсы, связанные с этим дескриптором сокета, используя для этого POSIX-совместимую функцию shutdown():

#include <winsock2.h>

int WSAAPI shutdown(SOCKET s, int how);

Параметры функции shutdown():

• s — дескриптор сокета.

• how — флаг запрещаемых операций:

• SD_RECEIVE — отключить операции приема данных.

• SD_SEND — отключить операции передачи данных.

• SD_BOTH — отключить и то и другое.

Функция завершит соединение, но так же, как в POSIX, не закроет дескриптор.

Кроме прочих существуют функции WSASendDisconnect(), WSARecvDisconnect() и DisconnectEx(). Их мы рассмотрим в книге 2.

Для освобождения дескриптора требуется вызвать функцию closesocket():

int WSAAPI closesocket(SOCKET s);

Так же как и функция close() в POSIX, данная функция при необходимости сама вызывает завершение соединения и удаляет объект, если закрываемый дескриптор — последний, который ссылается на объект сокета.

Обе функции вернут 0 в случае успеха и SOCKET_ERROR в случае неудачи.

Внеполосные данные

Внеполосные данные мы рассмотрели в главе 6. В документации Windows для WinSock написано, что c внеполосными данными он работает так же, как BSD-системы, однако это поведение можно изменить с помощью опции сокета TCP_EXPEDITED_1122.

Это флаг, включение которого задает поведение согласно RFC 1122 «Requirements for Internet Hosts».

Обратная ей опция TCP_BSDURGENT задает поведение «как у BSD-сокетов», то есть согласно RFC 793 «Transmission Control Protocol».

Если какая-либо из этих опций задана, выключить ее нельзя.

Прежде всего данные опции определяют, куда указывает метка внеполосных данных в совместимых BSD-сокетах, и при использовании этой опции. Варианты показаны на рис. 16.2.

Рис. 16.2. Разница между типами OOB-данных

Согласно RFC 793 указатель срочности указывает на порядковый номер октета, следующего за срочными данными. Однако в RFC 1122 говорится, что это ошибка и указатель должен указывать на последний октет срочных данных. Проблема в том, что ОС Windows поддерживает только один байт внеполосных данных, тогда как RFC 1122 говорит, что TCP должен поддерживать последовательности байтов срочных данных любой длины.

Кроме того, не прописано, будет ли ОС Windows буферизовать последующие внеполосные данные. Поэтому если программа читает данные медленно и в это время поступает еще один байт срочных данных, один из байтов может быть потерян.

На практике ОС Windows буферизует срочные данные. Однако использование внеполосной сигнализации TCP в WinSock ненадежно.

Различия между WinSock- и POSIX-сокетами

Хотя некоторые сокетные вызовы совместимы, можно заметить, что между ними и WinSock API существует достаточно различий. Эти различия необходимо учитывать при реализации кросс-платформенных приложений. Для удобства приведем их одним списком. Мы опишем различия не только в уже изученном API, но и в том, которое мы рассмотрим в следующих главах и книгах.

Очевидно, что отличаются состав и местоположение заголовочных файлов. Кроме этого, отличается расположение файлов конфигурации:

ОС

Файл узлов

Файл служб

Файл протоколов

POSIX

/etc/hosts

/etc/services

/etc/protocols

Windows

%SystemRoot%\System32\drivers\etc\hosts

%SystemRoot%\System32\drivers\etc\service

%SystemRoot%\System32\drivers\etc\protocols

Важным отличием WinSock является необходимость инициализации в начале работы и очистки при завершении работы.

Различия в составе вызовов WinAPI:

• В ОС Windows закрытие сокетов выполняется функцией closesocket().

• Функции read() и write()для WinSock-сокетов не работают. Хотя могут работать ReadFile() и WriteFile().

• Специфичные для ОС Windows функции, такие как WSAAccept(), WSAConnect() и т.п. Их аргументы и возвращаемые значения, как правило, не соответствуют их POSIX-аналогам.

• Функции-расширения от Microsoft: TransmitFile(), GetAcceptExSockaddrs() и прочие. Часть из них мы рассмотрим в книге 2.

• Функция ioctl(), которая позволяет задавать параметры устройств, в ОС Windows представлена функциями ioctlsocket(), WSAIoCtl() и DeviceIoControl(). Функция fcntl() отсутствует.

• Функции poll() в POSIX соответствует функция WSAPoll() в ОС Windows.

Различия в типах и значениях параметров и возвращаемых значениях:

• Числовые значения констант, обозначающих пространства адресов, например AF_INET, в разных ОС разные.

• Константы в getsockopt() и setsockopt() имеют разные значения.

• В POSIX тип дескриптора — int, в ОС Windows — макрос SOCKET раскрывается в тип void*.

• Дескрипторы в Windows — положительные числа. Нежелательно делать проверку на –1, лучше сравнивать дескриптор с константой INVALID_SOCKET.

• Возвращаемое значение функций, специфичных для ОС Windows, не стандартизовано.

• Все вызовы POSIX возвращают –1, устанавливая переменную errno. В ОС Windows POSIX-совместимые вызовы возвращают дополнение до единицы — макрос SOCKET_ERROR.

• Константы errno и коды ошибок в ОС Windows имеют разные значения. Дополнительную информацию об ошибке возможно получить с помощью вызова функции WSAGetLastError().

• Структуры addrinfo, используемые в функции getaddrinfo(), различаются: ai_addr и ai_canonname имеют разные смещения относительно начала структуры: их поменяли местами. При заполнении, а также сохранении и восстановлении структур это может вызывать проблемы.

• Функции обмена данными sendto(), recvfrom(), send(), recv() и т.п. в POSIX и в POSIX-совместимых Windows-функциях имеют различные типы аргументов. Поэтому если требуется передать большой объем данных, максимальный размер фрагмента стоит рассчитывать по меньшему типу.

• Флаги в функциях обмена данными имеют абсолютно разные значения. Поэтому всегда следует использовать только именованные константы, а не числа и проверять, что флаг существует в разных ОС.

• Значения флагов в полях структуры pollfd, используемые функциями poll() и WSAPoll(), различаются.

Внимание! Хотя на 32-битной системе проблем при работе не возникает, на 64-разрядной системе тип SOCKET от Microsoft в два раза больше по размеру, чем int, что может иметь значение при переносе приложений на другие платформы.

Структура POSIX для использования в select():

typedef struct

{

long fds_bits[FD_SETSIZE / 8 * sizeof(long)];

} fd_set;

Это отличается от структуры fd_set для функции select() в WinSock:

typedef struct fd_set

{

unsigned fd_count;

SOCKET fd_array[FD_SETSIZE];

} FDSET, *PFDSET;

Различий достаточно много, и, вероятно, чтобы их нивелировать, разумно использовать разные библиотеки. Рассматривать библиотеки мы начнем в книге 2 и продолжим в книге 3.

Провайдеры адресов и протоколов WinSock

Рассмотренный нами API для приложений носит название WSA. Кроме него Windows Sockets предоставляют API для расширения сокетной библиотеки — SPI, Service Provider Interface.

Функции WSA, как правило, выполняют минимум работы по диспетчеризации и согласованию результатов, а основные задачи переадресуют различным провайдерам, показанным на рис. 16.3. Это проистекает из самой организации сокетного API: вариантов пространств имен, протоколов и даже стеков в нем множество.

Существует два типа провайдеров:

• Провайдеры транспорта для выполнения задач по обмену данными.

• Провайдеры пространств имен для разрешения адресов.

Провайдеры транспорта выполняют работу по реальному обмену данными, например, провайдер транспорта MS TCP реализует TCP. Таких провайдеров может быть несколько, и они могут предоставлять несколько реализаций с разной функциональностью.

То же справедливо для функций и провайдеров, выполняющих разрешение имен.

Рис. 16.3. Провайдеры транспорта и пространств имен

Как правило, задавать провайдер не требуется явно, но в редких случаях необходимо выбирать реализацию, например, когда программист самостоятельно реализует протокол.

Примеры пространств имен, которые поддерживает Windows: DNS для Internet, доменные службы Active Directory, X.500. Они значительно различаются между собой.

Провайдеры реализуются в DLL с одной экспортируемой точкой входа, которая вызывается функцией инициализации: WSPStartup() или NSPStartup().

Иные функции провайдера доступны для Ws2_32.dll через таблицу диспетчеризации провайдера. Библиотеки провайдеров загружаются Ws2_32.dll при необходимости и выгружаются, когда больше не требуются.

Мы коснемся API для работы с провайдерами лишь в той мере, в какой он необходим для понимания работы с WSA.

Функции, работающие с провайдерами транспортов, имеют разные префиксы:

• WSP — Windows Sockets Providers;

• WPU — Windows Sockets Provider Upcall;

• WSC — Windows Sockets Configuration.

Функции для провайдеров пространств имен начинаются с NSP, что означает Namespace Provider.

Провайдеры транспорта

Рассмотрим интерфейс WinSock, изображенный на рис. 16.4. Приложение вызывает функции WinSock2 API из библиотеки Ws2_32.dll. Сама библиотека делегирует большую часть работы провайдерам.

Для выполнения операций по разрешению имен Ws2_32.dll вызывает сервисы провайдеров пространств имен, а для передачи сообщений — провайдеров транспорта.

Библиотека Mswsock.dll — это сокетные расширения от Microsoft, которые, начиная с ОС Windows Vista, были интегрированы с WinSock.

Провайдер TCP/IP реализован в библиотеке Wshtcpip.dll. Он взаимодействует с драйвером AFD, который позволяет отправлять и принимать сообщения по сети. Драйвер работает с разными протоколами и делегирует работу с TCP/IP соответствующему драйверу.

TCPIP.sys предоставляет интерфейс TLNPI — Transport Layer Network Provider Interface. Ранее предоставлялся интерфейс TDI — Transport Driver Interface.

Чтобы не переписывать старый код и сохранить обратную совместимость, для поддержки TDI в новых ядрах реализован TDX.sys — драйвер-преобразователь из TDI в TLNPI.

Между провайдером транспорта и пользовательским API, реализованным в Ws2_32.dll, работает интерфейс сервисного провайдера — WinSock Transport SPI. Он похож на Winsock API, так как все основные функции сокетов отображаются в функции провайдеров транспортов.

Например, функции socket() и WSASocket() сопоставляются с WSPSocket(), connect() и WSAConnect() — с WSPConnect(), send() и WSASend() — с WSPSend() и т.д. Но его функции начинаются с префикса WSP и доступны через адреса в таблице, которую заполняет провайдер.

Когда в API существует как новая версия функции WinSock, так и более старая версия, в SPI будет отражаться только новая версия.

Рис. 16.4. Вызов сетевых функций

Некоторые вспомогательные функции, такие как htonl(), htons(), ntohl() и ntohs(), реализованы в Ws2_32.dll и не реализуются провайдерами.

Функции манипулирования такими объектами, как события и ожидания, например WSACreateEvent(), WSASetEvent(), WSAWaitForMultipleEvents(), сопоставляются непосредственно со службами Windows и тоже не реализованы в SPI.

Нас интересуют не провайдеры как таковые, а протоколы, которые они поддерживают. Центральной структурой данных, объединяющей WSA и WSP, является структура WSAPROTOCOL_INFO, описывающая протокол. Прежде всего эта структура используется для загрузки провайдера, а затем — для привязки сокетов к этому провайдеру.

Рассмотрим ее первую часть, которая описывает службы и протокол:

typedef struct _WSAPROTOCOL_INFOW

{

// Битовая маска, описывающая службы, предоставляемые протоколом.

DWORD dwServiceFlags1;

// Зарезервированные поля.

DWORD dwServiceFlags2;

DWORD dwServiceFlags3;

DWORD dwServiceFlags4;

// Набор флагов c информацией о том, как протокол представлен

// в каталоге Winsock.

DWORD dwProviderFlags;

// GUID провайдера, реализующего протокол.

GUID ProviderId;

// Уникальный идентификатор, назначаемый WinSock каждой

// структуре WSAPROTOCOL_INFO.

DWORD dwCatalogEntryId;

Значения флагов в dwServiceFlags1 устанавливаются при регистрации провайдера и могут быть следующими:

• XP1_CONNECTIONLESS — предоставляет обмен данными без установления соединения, то есть это дейтаграммный протокол. Если данный флаг не установлен, протокол поддерживает передачу данных с установлением соединения.

• XP1_GUARANTEED_DELIVERY — гарантирует, что все отправленные данные будут доставлены.

• XP1_GUARANTEED_ORDER — гарантирует поступление данных в том порядке, в каком они были отправлены. Также гарантируется отсутствие дублирования.

• XP1_MESSAGE_ORIENTED — учитывает границы порций данных, то есть протокол ориентирован на сообщения.

• XP1_PSEUDO_STREAM — протокол, ориентированный на сообщения, но границы сообщений игнорируются для всех квитанций. Это удобно, когда приложение не хочет, чтобы кадрирование сообщения выполнялось протоколом.

• XP1_GRACEFUL_CLOSE — поддерживает двухфазное закрытие, как в TCP. Если данный флаг не установлен, будут выполняться только аварийные закрытия.

• XP1_EXPEDITED_DATA — поддерживаются внеполосные данные.

• XP1_CONNECT_DATA — поддерживаются данные, отправляемые при подключении.

• XP1_DISCONNECT_DATA — поддерживаются данные, отправляемые при отключении.

• XP1_SUPPORT_BROADCAST — поддерживается широковещание.

• XP1_SUPPORT_MULTIPOINT — поддерживается многоточечный или многоадресный режим.

• XP1_MULTIPOINT_CONTROL_PLANE — указывает, является ли плоскость управления корневой (1) или нет (0).

• XP1_MULTIPOINT_DATA_PLANE — указывает, является ли плоскость данных корневой (1) или некорневой (0).

• XP1_QOS_SUPPORTED — поддерживается качество обслуживания.

• XP1_INTERRUPT — зарезервирован.

• XP1_UNI_SEND — протокол может только отправлять данные.

• XP1_UNI_RECV — протокол может только получать данные.

• XP1_IFS_HANDLES — дескрипторы сокетов, возвращаемые провайдером, являются дескрипторами инсталлируемой файловой системы — IFS.

• XP1_PARTIAL_MESSAGE — флаг MSG_PARTIAL поддерживается WSASend() и WSASendTo().

• XP1_SAN_SUPPORT_SDP — протокол обеспечивает поддержку системных локальных сетей System area networks, или SAN.

Если флаги XP1_UNI_SEND или XP1_UNI_RECV не установлены, протокол двунаправленный.

Функциональная плоскость применительно к сетям — это абстрактное представление о том, где происходят определенные процессы.

Плоскость управления, или Control Plane136, — это часть сети, которая управляет пересылкой данных. Например, процесс создания таблицы маршрутизации считается частью плоскости управления.

Плоскость данных выполняет непосредственную пересылку данных.

Чаще эти термины можно встретить при описании работы маршрутизаторов.

Флаги в dwProviderFlags устанавливаются аналогично флагам выше:

• PFL_MULTIPLE_PROTO_ENTRIES — указывает, что это одна из нескольких записей одного протокола, реализующего несколько вариантов поведения. Например, протокол на принимающей стороне может вести себя как ориентированный на сообщения или как ориентированный на поток.

• PFL_RECOMMENDED_PROTO_ENTRY — рекомендуемая или наиболее часто используемая запись для протокола, реализующего несколько вариантов поведения.

• PFL_HIDDEN — указывает Ws2_32.dll, что этот протокол не должен возвращаться в буфер результатов, созданный WSAEnumProtocols(). То есть приложение, использующее Windows Sockets 2, не увидит его запись.

• PFL_MATCHES_PROTOCOL_ZERO — указывает, что значение нуля в параметре протокола функций socket() и WSASocket() соответствует этой записи протокола. То есть это протокол, используемый по умолчанию.

• PFL_NETWORKDIRECT_PROVIDER — провайдер поддерживает прямой доступ к сети.

Оставшаяся часть структуры также содержит параметры протокола:

// Структура, которая содержит цепочку протоколов.

WSAPROTOCOLCHAIN ProtocolChain;

// Идентификатор версии протокола.

int iVersion;

// Семейство адресов как в функции socket(): AF_INET, AF_INET6 и т.д.

int iAddressFamily;

// Максимальная длина адреса в байтах.

int iMaxSockAddr;

// Минимальная длина адреса в байтах.

int iMinSockAddr;

// Тип сокета как в функции socket():

// SOCK_STREAM, SOC_DGRAM, SOCK_RAW и т.д.

int iSocketType;

// Протокол как в функции socket(): IPPROTO_TCP, IPPROTO_UDP и т.д.

int iProtocol;

// Максимальное значение, которое может быть добавлено к iProtocol

// при передаче значения параметра протокола функции socket().

// Равен 0, если протокол не допускает использования

// диапазона идентификаторов.

int iProtocolMaxOffset;

// Порядок байтов, используемый протоколом:

// BIGENDIAN (0) или LITTLEENDIAN (1).

int iNetworkByteOrder;

// Тип используемой схемы безопасности.

// Если протокол не использует средства безопасности, содержит

// SECURITY_PROTOCOL_NONE (0).

int iSecurityScheme;

// Максимальный размер сообщения, который может отправить узел.

//   0 — потоковый протокол.

//   1 — максимальный размер исходящего сообщения зависит от MTU.

//   0xffffffff — протокол, ориентированный на сообщения, но ограничения

//     на размер сообщений нет.

DWORD dwMessageSize;

// Зарезервировано сервис-провайдером.

DWORD dwProviderReserved;

// Имя протокола, например "MSAFD Tcpip [UDP/IP]".

// Максимально допустимое количество символов — WSAPROTOCOL_LEN

// (255 символов).

// Это поле содержит отличие данной структуры от WSAPROTOCOL_INFOA.

// В общем случае необходимо считать, что оно имеет тип TCHAR.

WCHAR szProtocol[WSAPROTOCOL_LEN + 1];

} WSAPROTOCOL_INFOW, *LPWSAPROTOCOL_INFOW;

Большинство функций, относящихся к провайдерам, кроме высокоуровневых функций их перечисления, объявлены в заголовочном файле ws2spi.h.

GUID, необходимый для регистрации провайдера, можно сгенерировать, используя команду PowerShell:

PS C:\> [guid]::NewGuid()

Guid

----

2f2a48b5-9981-46c2-a63b-80322484ca86

Классы протоколов, предоставляемых провайдерами

Существует несколько классов протоколов:

• Многоуровневые протоколы, или Layered Protocols, реализуют функции связи высокого уровня, используя существующий транспортный стек для обмена данными. Примером этого типа протокола является TLS. Он добавляет протокол для выполнения аутентификации и выбора схемы шифрования. Работает он поверх надежного транспорта, например TCP.

• Базовые протоколы способны выполнять обмен данными с удаленным абонентом. Пример таких протоколов — TCP или UDP.

• Цепочка протоколов — это несколько многоуровневых протоколов, объединенных вместе и привязанных к базовому протоколу. То есть цепочка протоколов — это полный стек, который может быть использован для обмена данными.

Структура цепочки протоколов:

typedef struct _WSAPROTOCOLCHAIN

{

// Длина цепочки:

//   0 — многоуровневый протокол, 1 — базовый,

//   иные значения — цепочка протоколов.

int ChainLen;

// Идентификатор в каталоге — dwCatalogEntryId.

DWORD ChainEntries[MAX_PROTOCOL_CHAIN];

} WSAPROTOCOLCHAIN, *LPWSAPROTOCOLCHAIN;

На рис. 16.5 видно, что приложения могут непосредственно использовать только базовые протоколы и цепочки протоколов.

Рис. 16.5. Многоуровневые и основные протоколы

Внимание! Layered Service Providers и API для работы с ним устарели и не рекомендованы к использованию. В современных приложениях вместо них лучше использовать Windows Filtration Platform.

Многоуровневые протоколы и реализующие их многоуровневые провайдеры, или Layered Service Providers — LSP, устарели. Для их создания в структуре WSAPROTOCOL_INFO просто указывался путь к первому многоуровневому провайдеру в цепочке, и эта структура передавалась в его метод WSPStartup(). Многоуровневый провайдер обрабатывал часть функций, а для тех функций, которые не относятся к нему, вызывал функции нижележащего провайдера.

Подобные LSP ранее использовались для регулирования скорости закачки, фильтрации содержимого и прочего. Например, антивирус NOD32 использовал их для сканирования трафика, а прокси-сервер ProxyCap — для организации прозрачной отправки данных через прокси.

Перечисление провайдеров транспорта

Информацию о доступных провайдерах транспорта вместе с протоколами предоставляет функция WSAEnumProtocols():

int WSAAPI WSAEnumProtocols(

LPINT lpiProtocols,

LPWSAPROTOCOL_INFO lpProtocolBuffer,

LPDWORD lpdwBufferLength

);

Параметры функции WSAEnumProtocols():

• lpiProtocols — завершенный нулем массив идентификаторов протоколов. Параметр опциональный. Если он нулевой, будет возвращена информация обо всех доступных протоколах, иначе — только о тех, что перечислены в массиве.

• lpProtocolBuffer — буфер, который будет заполнен массивом структур WSAPROTOCOL_INFO.

• lpdwBufferLength — количество байтов в переданном буфере. Если размер буфера недостаточен, функция запишет сюда его необходимый размер.

В случае успеха функция возвращает число протоколов в буфере, иначе — SOCKET_ERROR.

Посмотрим, какие провайдеры есть в системе. Для этого вызовем функцию их перечисления и выведем свойства.

Первый вызов почти наверняка завершится с ошибкой из-за нехватки места в буфере структур:

...

int main()

{

const socket_wrapper::SocketWrapper sw;

// Вектор для хранения провайдеров протоколов.

std::vector<WSAPROTOCOL_INFO> protocol_info(1);

DWORD real_buffer_len = protocol_info.size() * sizeof(WSAPROTOCOL_INFO);

// Первый вызов функции наверняка завершится с ошибкой из-за

// недостаточного размера буфера.

auto info_count = WSAEnumProtocols(nullptr, protocol_info.data(),

&real_buffer_len);

Требуемый размер буфера вернется в переменной real_buf_len. Расширим буфер и повторим вызов:

if (SOCKET_ERROR == info_count)

{

if (int e_code = WSAGetLastError(); e_code != WSAENOBUFS)

{

// Какая-то другая ошибка.

std::cerr << "WSAEnumProtocols failed with error: "

<< e_code << std::endl;

return EXIT_FAILURE;

}

else

{

std::cerr << "WSAEnumProtocols failed with error: WSAENOBUFS "

<< e_code << std::endl;

std::cout << "Increasing buffer size to "

<< real_buffer_len << std::endl;

// Увеличить размер буфера до того, который вернула функция.

protocol_info.resize(real_buffer_len /

sizeof(WSAPROTOCOL_INFO) + 1);

real_buffer_len = protocol_info.size() * sizeof(WSAPROTOCOL_INFO);

// Повторно вызвать функцию с буфером необходимого размера.

info_count = WSAEnumProtocols(nullptr, protocol_info.data(),

&real_buffer_len);

if (SOCKET_ERROR == info_count)

{

std::cerr << "WSAEnumProtocols failed with error: "

<< WSAGetLastError() << std::endl;

return EXIT_FAILURE;

}

}

}

Остается идти по вектору и выводить свойства разных провайдеров. Сначала выведем их общие данные, такие как название, протокол, номер в каталоге и т.п.:

std::cout << "WSAEnumProtocols succeeded with protocol count = "

<< info_count << std::endl;

for (size_t i = 0; i < info_count; ++i)

{

// Вывести данные сокетного провайдера.

std::cout

<< "Winsock Catalog Provider Entry " << i << "\n"

<< "Catalog Entry ID: "

<< protocol_info[i].dwCatalogEntryId << "\n"

<< "Version: " << protocol_info[i].iVersion << "\n"

<< "Entry type: "

<< ((1 == protocol_info[i].ProtocolChain.ChainLen) ?

"Base Service Provider" : "Layered Chain Entry")

<< "\n"

<< "Protocol: " << protocol_info[i].szProtocol << "\n"

<< "Protocol Chain length: "

<< protocol_info[i].ProtocolChain.ChainLen << "\n"

<< std::endl;

std::wstring guid_string(40, 0);

// Преобразовать GUID провайдера в строку и вывести GUID.

if (!StringFromGUID2(protocol_info[i].ProviderId,

reinterpret_cast<LPOLESTR>(guid_string.data()),

guid_string.size() — 1))

{

std::cerr << "StringFromGUID2 failed" << std::endl;

}

else

{

std::wcout << "Provider GUID: " << guid_string << std::endl;

}

Выведем оставшиеся поля:

std::cout

<< "Address Family: " << protocol_info[i].iAddressFamily << "\n"

<< "Max Socket Address Length: "

<< protocol_info[i].iMaxSockAddr << "\n"

<< "Min Socket Address Length: "

<< protocol_info[i].iMinSockAddr << "\n"

<< "Socket Type: " << protocol_info[i].iSocketType << "\n"

<< "Socket Protocol: " << protocol_info[i].iProtocol << "\n"

<< "Socket Protocol Max Offset: "

<< protocol_info[i].iProtocolMaxOffset << "\n"

<< "Network Byte Order: "

<< protocol_info[i].iNetworkByteOrder << "\n"

<< "Security Scheme: " << protocol_info[i].iSecurityScheme << "\n"

<< "Max Message Size: " << protocol_info[i].dwMessageSize << "\n"

<< std::endl;

}

return EXIT_SUCCESS;

}

Запустим пример. Провайдеров в системе, где запускалась утилита, порядка 11 штук, поэтому сократим вывод и покажем лишь несколько примеров:

D:\build\bin> b01-ch16-enum-protocols.exe

WSAEnumProtocols failed with error: WSAENOBUFS 10055

Increasing buffer size to 4464

WSAEnumProtocols succeeded with protocol count = 12

Winsock Catalog Provider Entry 0

Catalog Entry ID: 1001

Version: 2

Entry type: Base Service Provider

Protocol: MSAFD Tcpip [TCP/IP]

Protocol Chain length: 1

Provider GUID: {E70F1AA0-AB8B-11CF-8CA3-00805F48A192}

Address Family: 2

Max Socket Address Length: 16

Min Socket Address Length: 16

Socket Type: 1

Socket Protocol: 6

Socket Protocol Max Offset: 0

Network Byte Order: 0

Security Scheme: 0

Max Message Size: 0

Первый в списке — провайдер TCP по умолчанию от Microsoft. Он предоставляет TCP поверх IPv4. Следующий — провайдер UDP поверх IPv4 от Microsoft:

Winsock Catalog Provider Entry 1

Catalog Entry ID: 1002

Version: 2

Entry type: Base Service Provider

Protocol: MSAFD Tcpip [UDP/IP]

Protocol Chain length: 1

Provider GUID: {E70F1AA0-AB8B-11CF-8CA3-00805F48A192}

Address Family: 2

Max Socket Address Length: 16

Min Socket Address Length: 16

Socket Type: 2

Socket Protocol: 17

Socket Protocol Max Offset: 0

Network Byte Order: 0

Security Scheme: 0

Max Message Size: 65527

TCP-провайдер, который предоставляет TCP поверх IPv6, реализован отдельно:

Winsock Catalog Provider Entry 2

Catalog Entry ID: 1004

Version: 2

Entry type: Base Service Provider

Protocol: MSAFD Tcpip [TCP/IPv6]

Protocol Chain length: 1

Provider GUID: {F9EAB0C0-26D4-11D0-BBBF-00AA006C34E4}

Address Family: 23

Max Socket Address Length: 28

Min Socket Address Length: 28

Socket Type: 1

Socket Protocol: 6

Socket Protocol Max Offset: 0

Network Byte Order: 0

Security Scheme: 0

Max Message Size: 0

Аналогично для UDP и других протоколов. Другие провайдеры предоставляют работу с Bluetooth, поддержку связи по Hyper-V и т.п.

Вот, например, уже упомянутый провайдер сокетов AF_UNIX:

Winsock Catalog Provider Entry 4

Catalog Entry ID: 1007

Version: 2

Entry type: Base Service Provider

Protocol: AF_UNIX

Protocol Chain length: 1

Provider GUID: {A00943D9-9C2E-4633-9B59-0057A3160994}

Address Family: 1

Max Socket Address Length: 110

Min Socket Address Length: 2

Socket Type: 1

Socket Protocol: 0

Socket Protocol Max Offset: 0

Network Byte Order: 0

Security Scheme: 0

Max Message Size: 0

От функции WSAEnumProtocols() отличается сервисная функция WSCEnumProtocols(), которая возвращает полный список протоколов, в том числе тех, для которых установлен флаг PFL_HIDDEN в поле dwProviderFlags структуры WSAPROTOCOL_INFO, и фиктивных провайдеров LSP, у которых нулевая длина цепочки в ProtocolChain.ChainLen:

int WSCEnumProtocols(

LPINT lpiProtocols,

LPWSAPROTOCOL_INFO lpProtocolBuffer,

LPDWORD lpdwBufferLength,

LPINT lpErrno

);

Параметры функции те же, что и для WSA-аналога, кроме указателя на код ошибки — lpErrno.

Перечисление выполняется в порядке установки провайдеров. Это тот же порядок, в каком рассматриваются протоколы, когда запрашивается создание нового сокета.

Функция WSCWriteProviderOrder() из Sporder.dll позволяет установить новый порядок:

#include <sporder.h>

int WSCWriteProviderOrder(

LPDWORD lpwdCatalogEntryId,

DWORD dwNumberOfEntries);

Параметры функции WSCWriteProviderOrder():

• lpwdCatalogEntryId — массив идентификаторов провайдеров, который определяет их порядок.

• dwNumberOfEntries — количество идентификаторов в массиве.

Существует утилита Sporder.exe, позволяющая переупорядочить каталог провайдеров.

Установка и удаление провайдеров транспорта

Чтобы транспортный протокол стал доступен приложениям, он должен быть установлен и зарегистрирован в WinSock.

Чтобы зарегистрировать новый провайдер, требуется предоставить одну или несколько заполненных структур WSAPROTOCOL_INFO и вызвать функцию установки провайдера:

#include <ws2spi.h>

int WSCInstallProvider(

LPGUID lpProviderId,

const WCHAR *lpszProviderDllPath,

const LPWSAPROTOCOL_INFOW lpProtocolInfoList,

DWORD dwNumberOfEntries,

LPINT lpErrno

);

int WSCInstallProviderAndChains(

LPGUID lpProviderId,

const LPWSTR lpszProviderDllPath,

const LPWSTR lpszLspName,

DWORD dwServiceFlags,

LPWSAPROTOCOL_INFOW lpProtocolInfoList,

DWORD dwNumberOfEntries,

LPDWORD lpdwCatalogEntryId,

LPINT lpErrno

);

Параметры функций WSCInstallProvider() и WSCInstallProviderAndChains:

• lpProviderId — уникальный идентификатор провайдера.

• lpszProviderDllPath — путь к DLL провайдера.

• lpszLspName — указатель на имя провайдера.

• dwServiceFlags — флаги для LSP. Всего определен только один флаг — XP1_IFS_HANDLES. Его назначение можно узнать в MSDN; напоминаем, что использовать данный API для установки LSP не рекомендуется.

• lpProtocolInfoList — указатель на массив структур WSAPROTOCOL_INFO. Каждая структура определяет протокол, семейство адресов и тип сокета, поддерживаемые данным провайдером.

• dwNumberOfEntries — количество структур в массиве.

• lpdwCatalogEntryId — указатель на недавно установленную «фиктивную» запись для провайдера транспорта в базе данных конфигурации системы Winsock 2. Этот идентификатор используется для установки записей каталога LSP.

• lpErrno — указатель на ошибку.

В случае успеха функции возвращают 0, в случае ошибки — SOCKET_ERROR, а код ошибки записывают по указателю в lpErrno.

Функция WSCDeinstallProvider() выполняет удаление провайдера:

int WSCDeinstallProvider(LPGUID lpProviderId, LPINT lpErrno);

Создание и разрушение сокета

Функция WPUCreateSocketHandle() создает новый дескриптор сокета для указанного провайдера транспорта.

Функция принимает два параметра: дескриптор сокета и указатель на код ошибки.

#include <ws2spi.h>

SOCKET WPUCreateSocketHandle(

DWORD dwCatalogEntryId,

DWORD_PTR dwContext,

LPINT lpErrno

);

Параметры функции WPUCreateSocketHandle():

• dwCatalogEntryId — идентификатор записи каталога.

• dwContext — значение контекста.

• lpErrno — указатель на код ошибки.

Функция возвращает новый дескриптор сокета в случае успеха или INVALID_SOCKET, если произошла ошибка.

Функция WPUCloseSocketHandle() закрывает существующий дескриптор сокета, созданный функцией WPUCreateSocketHandle():

int WPUCloseSocketHandle(SOCKET s, LPINT lpErrno);

Функция возвращает 0, если ошибки не произошло, или SOCKET_ERROR, если возникла ошибка. В этом случае код ошибки доступен в указателе на него.

Провайдеры имен

Кратко рассмотрим API провайдеров пространств имен. Их основное назначение — работа по разрешению адресов, то есть запрос функций типа getaddrinfo() на разрешение www.google.com в IP-адрес будет выполнен провайдером пространств имен.

Такой подход не нов и похож на подход в современных Unix-подобных системах, в которых GLibC реализует сложный многоуровневый резолвер.

Перечисление провайдеров имен

Следующие функции возвращают доступные провайдеры пространств имен:

#include <winsock2.h>

typedef struct _WSANAMESPACE_INFO

{

// Уникальный идентификатор провайдера.

GUID NSProviderId;

// Поддерживаемые провайдером пространства имен: NS_DNS, NS_EMAIL и т.п.

DWORD dwNameSpace;

// Если истина — провайдер активен.

bool fActive;

// Версия провайдера.

DWORD dwVersion;

// Отображаемый идентификатор.

LPTSTR lpszIdentifier;

// Далее идут данные, зависящие от провайдера в WSANAMESPACE_INFOEX.

// BLOB ProviderSpecific;

} WSANAMESPACE_INFO, *PWSANAMESPACE_INFO, *LPWSANAMESPACE_INFO;

int WSAAPI WSAEnumNameSpaceProviders(

LPDWORD lpdwBufferLength,

LPWSANAMESPACE_INFO lpnspBuffer

);

int WSAAPI WSAEnumNameSpaceProvidersEx(

LPDWORD lpdwBufferLength,

LPWSANAMESPACE_INFOEX lpnspBuffer

);

Параметры функций WSAEnumNameSpaceProviders() и WSAEnumNameSpaceProvidersEx():

• lpdwBufferLength — количество байтов в буфере, в который возвращается информация о провайдерах. Сюда же будет записано необходимое количество байтов, если размер буфера недостаточен.

• lpnspBuffer — массив структур WSANAMESPACE_INFO или WSANAMESPACE_INFOEX, расположенных последовательно.

В случае успеха функции возвращают количество структур в буфере, иначе — SOCKET_ERROR, а номер ошибки можно получить, вызвав WSAGetLastError().

В 64-битных ОС Windows существуют 32-битные функции, такие как WSCInstallNameSpace32() и WSCEnumNameSpaceProvidersEx32().

Эти функции оперируют 32-битным каталогом провайдеров, который разделен с 64-битным.

Данные функции нужны в целях совместимости.

Управлять порядком перечисления можно с помощью следующей функции:

#include <sporder.h>

int WSCWriteNameSpaceOrder(LPGUID lpProviderId, DWORD dwNumberOfEntries);

Параметры функции WSCWriteNameSpaceOrder():

• lpProviderId — массив идентификаторов провайдеров, который определяет их порядок.

• dwNumberOfEntries — количество идентификаторов в массиве.

Функция вернет 0 в случае успеха или код ошибки в случае неудачи.

Установка и удаление провайдеров имен

В то время как провайдеры транспорта предоставляют услуги обмена данными, провайдеры имен предоставляют услуги конвертации IP-адресов и доменных имен.

Провайдеры должны быть зарегистрированы в пространстве имен. Существует несколько типов таких пространств.

Статические пространства имен требуют, чтобы все службы были зарегистрированы заранее, то есть при создании пространства имен. Примеры такого пространства — файлы узлов, протоколов и служб, используемые во многих реализациях TCP/IP.

В ОС Windows они находятся в каталоге %SystemRoot%\system32\drivers\etc.

Постоянные пространства имен позволяют службам регистрироваться в процессе исполнения. Сохраняют регистрационную информацию в постоянном хранилище, где она остается до тех пор, пока служба не запросит ее удаление. Такие пространства имен типичны для служб каталогов, например X.500 и DNS.

Хотя существует программный способ разрешения имен DNS с помощью сокетов Windows, провайдер пространства имен DNS в Windows не поддерживает регистрацию новых DNS-имен в постоянном хранилище с помощью WinSock.

Динамические пространства имен позволяют службам регистрироваться в процессе исполнения. Они не сохраняют регистрационную информацию в постоянное хранилище. Такие пространства часто полагаются на широковещательные рассылки, чтобы указать на доступность сетевой службы.

Пример такого пространства — протокол привязки имен NBP, применяемый в AppleTalk, или пространство PNRP — протокол разрешения одноранговых имен.

Устанавливают провайдер пространства имен функции WSCInstallNameSpace() и WSCInstallNameSpaceEx().

Рассмотрим их прототипы:

#include <ws2spi.h>

int WSCInstallNameSpace(

LPWSTR lpszIdentifier,

LPWSTR lpszPathName,

DWORD dwNameSpace,

DWORD dwVersion,

LPGUID lpProviderId

);

int WSCInstallNameSpaceEx(

LPWSTR lpszIdentifier,

LPWSTR lpszPathName,

DWORD dwNameSpace,

DWORD dwVersion,

LPGUID lpProviderId,

LPBLOB lpProviderSpecific

);

Параметры функций WSCInstallNameSpace() и WSCInstallNameSpaceEx():

• lpszIdentifier — указатель на строку-идентификатор провайдера.

• lpszPathName — путь к DLL провайдера. В строке поддерживается подстановка переменных, например %SystemRoot%.

• dwNameSpace — идентификатор пространства имен, которое поддерживается данным провайдером.

• dwVersion — версия провайдера.

• lpProviderId — уникальный идентификатор провайдера.

• lpProviderSpecific — данные, зависящие от провайдера.

Для провайдеров, способных поддерживать несколько пространств имен, эти функции необходимо вызывать для каждого поддерживаемого пространства имен, и каждый раз должен предоставляться уникальный идентификатор провайдера.

Функция WSCUnInstallNameSpace() удаляет провайдер:

int WSCUnInstallNameSpace(LPGUID lpProviderId);

В случае успеха функции возвращают ноль, иначе — SOCKET_ERROR.

Похоже, что данные в атрибуте ProviderSpecific используются единственным провайдером имен — NS_EMAIL, который позволяет работать с именами электронной почты.

Структура NAPI_PROVIDER_INSTALLATION_BLOB, определенная в nsemail.h, позволяет задать факт поддержки регулярных выражений и список доменов при установке провайдера.

А структура NAPI_DOMAIN_DESCRIPTION_BLOB, возвращаемая перечислением, содержит домен, с которым работает этот провайдер имен.

Все функции установки и удаления провайдеров требуют прав администратора. Также администратор или пользователь, имеющий сходные права, может включать и выключать провайдеры имен:

#include <ws2spi.h>

int WSCEnableNSProvider(LPGUID lpProviderId, bool fEnable);

Параметры функции WSCEnableNSProvider():

• lpProviderId — GUID провайдера.

• fEnable — включить или выключить провайдер.

Возвращаемые значения те же, что и для функций выше.

Запуск провайдера

Когда приложение создает новый сокет, который будет связан с провайдером, WinSock инициирует загрузку DLL провайдера, после чего вызывает функцию WSPStartup():

#include <ws2spi.h>

int WSPStartup(

WORD wVersionRequested,

LPWSPDATA lpWSPData,

LPWSAPROTOCOL_INFOW lpProtocolInfo,

WSPUPCALLTABLE upcallTable,

LPWSPPROC_TABLE lpProcTable

);

Параметр wVersionRequested содержит максимальную версию SPI, которая может быть использована в вызове.

Параметр upcallTable входной и содержит адреса функций, которые используются провайдером, а в lpProcTable провайдер записывает адреса вызовов:

WSPUPCALLTABLE upcallTable =

{

// Инициализировать таблицу адресами вызовов.

};

// Переменная, в которую будут сохранены вызовы.

LPWSPPROC_TABLE lpProcTable;

wVersionRequested = MAKEWORD(2, 2);

if (WSPStartup(wVersionRequested, &WSPData, lpProtocolBuffer,

upcallTable, lpProcTable) != 0)

{

return;

}

// Проверить, что провайдер поддерживает версию WinSock 2.2.

if (LOBYTE(WSPData.wVersion) != 2 || HIBYTE(WSPData.wVersion) != 2)

{

// Нужный провайдер не был найден.

LPWSPCleanup();

return;

}

Провайдер содержит счетчик ссылок для каждого процесса, а WSPStartup() может вызываться несколько раз, и поэтому для каждого успешного вызова должен быть вызван WSPCleanup().

В процессе инициализации Ws2_32.dll извлекает таблицу диспетчеризации, полученную в качестве параметра WSPStartup(), из атрибута lpProcTable.

Для библиотек, приложений и провайдеров транспорта могут существовать разные версии, от которых зависит набор функций, параметров структур данных и т.п.

Согласование версии обрабатывается функцией WSPStartup().

Ws2_32.dll передает провайдеру наивысшие номера версий, с которыми совместима библиотека.

Провайдер сравнивает версии с собственным поддерживаемым диапазоном номеров версий и возвращает значение из перекрывающейся части диапазона, обычно максимально возможное.

Если диапазоны не перекрываются, то есть стороны несовместимы, функция возвращает ошибку.

При каждом вызове WSPStartup() может указываться любой номер версии, поддерживаемой библиотекой провайдера, но если процесс вызывает WSPStartup() несколько раз, провайдер может использовать только последний указатель на таблицу.

Использование таблицы вызовов более удобно: можно сделать один вызов, чтобы обнаружить весь набор функций. Кроме того, это позволяет цепочкам провайдеров работать более эффективно.

Таблицы выглядят следующим образом:

typedef struct WSPData

{

WORD wVersion;

WORD wHighVersion;

WCHAR szDescription[WSPDESCRIPTION_LEN + 1];

} WSPDATA, *LPWSPDATA;

// Адреса функций, используемых провайдером.

typedef struct _WSPUPCALLTABLE

{

LPWPUCLOSEEVENT lpWPUCloseEvent;

LPWPUCLOSESOCKETHANDLE lpWPUCloseSocketHandle;

LPWPUCREATEEVENT lpWPUCreateEvent;

LPWPUCREATESOCKETHANDLE lpWPUCreateSocketHandle;

...

LPWPUSETEVENT lpWPUSetEvent;

LPWPUOPENCURRENTTHREAD lpWPUOpenCurrentThread;

LPWPUCLOSETHREAD lpWPUCloseThread;

} WSPUPCALLTABLE, *LPWSPUPCALLTABLE;

Адреса функций провайдера, которые он возвращает для использования в API:

typedef struct _WSPPROC_TABLE

{

LPWSPACCEPT lpWSPAccept;

LPWSPADDRESSTOSTRING lpWSPAddressToString;

LPWSPASYNCSELECT lpWSPAsyncSelect;

LPWSPBIND lpWSPBind;

...

LPWSPSEND lpWSPSend;

LPWSPSENDDISCONNECT lpWSPSendDisconnect;

LPWSPSENDTO lpWSPSendTo;

LPWSPSETSOCKOPT lpWSPSetSockOpt;

LPWSPSHUTDOWN lpWSPShutdown;

LPWSPSOCKET lpWSPSocket;

LPWSPSTRINGTOADDRESS lpWSPStringToAddress;

} WSPPROC_TABLE, *LPWSPPROC_TABLE;

Точно так же инициализация производится для провайдеров имен:

#include <ws2spi.h>

typedef struct _NSP_ROUTINE

{

// Размер структуры в байтах.

DWORD cbSize;

// Номера версии спецификации, поддерживаемой сервис-провайдером.

DWORD dwMajorVersion;

DWORD dwMinorVersion;

// Указатели на функции.

LPNSPCLEANUP NSPCleanup;

LPNSPLOOKUPSERVICEBEGIN NSPLookupServiceBegin;

LPNSPLOOKUPSERVICENEXT NSPLookupServiceNext;

LPNSPLOOKUPSERVICEEND NSPLookupServiceEnd;

LPNSPSETSERVICE NSPSetService;

LPNSPINSTALLSERVICECLASS NSPInstallServiceClass;

LPNSPREMOVESERVICECLASS NSPRemoveServiceClass;

LPNSPGETSERVICECLASSINFO NSPGetServiceClassInfo;

LPNSPIOCTL NSPIoctl;

} NSP_ROUTINE, *LPNSP_ROUTINE;

int WSAAPI NSPStartup(

LPGUID lpProviderId,

LPNSP_ROUTINE lpnspRoutines

);

После каждого вызова NSPStartup() также должен вызываться NSPCleanup(), который возвращает провайдер имен в таблице lpnspRoutines.

Провайдер имен версии 2 будет использовать структуру NSPV2_ROUTINE. Суть та же, но указатели на функции отличаются. Мы не будем ее рассматривать.

В силу ограниченного размера книги не будем рассматривать в ней полную реализацию провайдеров.

Провайдеры требуется реализовывать достаточно редко, но их код имеет свои особенности. Например, провайдеры должны предоставлять достаточно большое число вызовов, которые используются вышележащим API.

Резюме

Сокетный API для обмена данными имеет как POSIX-совместимые функции, так и реализованные только в WinSock.

Чтобы создать новый сокет в ОС Windows, используются функции socket() и WSASocket(). Последняя из них является специфичной для Windows и позволяет указывать дополнительные флаги, а также использовать заданный провайдер транспорта, который будет обслуживать сокет.

Для сокетов в ОС Windows поддерживаются все основные адресные семейства, которые есть в Unix-подобных системах. Некоторые типы сокетов, например raw-сокеты, имеют достаточно много ограничений, а использовать их могут только члены группы администраторов.

В ОС Windows 10 появились также Unix-domain-сокеты, что теперь позволяет создавать IPC, переносимые между Unix-подобными системами и ОС Windows.

Привязка адреса к сокету выполняется так же, как в POSIX API, вызовом функции bind().

Сокеты Windows позволяют выполнять блокирующий и неблокирующий обмен данными. Вариантов реализации неблокирующего ввода-вывода в Windows существует несколько, их мы рассмотрим в книге 2.

Для связи без установки соединения используются как уже знакомые нам функции recvfrom() и sendto(), так и специфичные для ОС Windows — WSARecvFrom() и WSASendTo(). Последние в данном случае отличаются только возможностью производить асинхронный ввод-вывод.

Для связи с установлением соединения на стороне клиента используются функции connect() и WSAConnect(), а также более удобные WSAConnectByName() и WSAConnectByList().

Чтобы перевести сокет в состояние прослушивания, используется функция listen(), а для приема запроса на соединение от клиента — функции accept() и WSAAccept(). Получение данных на подключенном сокете выполняется функциями recv() и WSARecv(), а отправка — функциями send(), WSASend().

Обмен данными реализован в провайдерах транспорта, которые являются разделяемыми библиотеками. А разрешение имен реализуют провайдеры имен.

Из-за того что провайдеры должны быть зарегистрированы в системе, существуют API для установки провайдеров, их перечисления и удаления.

Сокеты можно связывать с провайдерами, WinSock при этом инициирует загрузку DLL-провайдера, после чего вызывает функцию WSPStartup().

Вопросы и задания

1. Когда необходимо использовать функцию WSASocket() для создания нового сокета в WinSock? А когда — функцию socket()?

2. Почему для функции bind() не существует WSA-аналога, например WSABind()?

3. Могут ли в ОС Windows работать приложения, использующие Unix-сокеты, без значительных изменений?

4. Чем отличается работа с raw-сокетами в ОС Windows от работы с ними же в Linux?

5. Каковы основные отличия блокирующего ввода-вывода на сокетах от неблокирующего? Какие плюсы и минусы есть у каждого?

6. Для чего нужны функции WSARecvFrom() и WSASendTo()? В чем их отличие от recvfrom() и sendto()?

7. Для чего нужен флаг MSG_PARTIAL в функции WSASendTo()? Каковы его особенности?

8. В чем преимущества и недостатки функций WSAConnectByName() и WSAConnectByList() по сравнению с connect()?

9. Как правильно выбрать значение параметра backlog функции listen() в ОС Windows?

10. Что позволяет делать функция WSAAccept() по сравнению с accept()?

11. Какая функция закрывает сокет в WinSock?

12. В чем особенности работы с внеполосными данными в ОС Windows?

13. Назовите три характерных отличия POSIX-сокетов от Windows-сокетов. Как нивелировать эти отличия при разработке приложения?

14. Чем отличается тип SOCKET на 64-разрядной системе от типа дескриптора в POSIX?

15. Зачем нужны провайдеры транспорта? А провайдеры пространств имен?

16. Все ли функции сокетного API Windows реализуются провайдерами? Если нет, то какие не реализуются? Почему?

17. В каких случаях требуется реализация собственных провайдеров?

18. Какие классы протоколов могут поддерживать провайдеры транспорта?

19. Стоит ли использовать LSP при разработке новых приложений?

20. Для чего нужна функция NSPStartup() и когда она будет вызвана?

21. В примере, выводящем провайдеры, мы вывели их списком. Определите, какие именно провайдеры были выведены и за что они отвечают.

22. Создайте клиент, используя функции WinSock, ориентированные на соединение. Клиент должен отправлять данные из командной строки.

23. Создайте сервер, используя функции WinSock, ориентированные на соединение. Сервер должен выводить на экран всю информацию, которая приходит от клиента.

136 https://en.wikipedia.org/wiki/Control_plane

Глава 17. Альтернативы сокетам в ОС Windows

Интернет? Мы в этом не заинтересованы.

Билл Гейтс, 1993

Введение

Кроме сокетов, ОС Windows предоставляет достаточно большое количество способов межпроцессного взаимодействия. В этой главе мы рассмотрим те из них, которые могут работать поверх сети, реализуя обмен между приложениями на разных узлах. Таковыми являются почтовые слоты или ящики, а также именованные каналы.

Для этих способов существуют понятия сервера и клиента:

• Сервер — тот, кто создает ящик или дает возможность подключения к каналу с определенным именем. Он должен предоставить имя канала либо ящика своим клиентам.

• Клиент — использует для обмена уже созданные ящики или каналы.

Чтобы начать обмен, клиент должен знать имя сервера. Часто оно может быть просто зашито в коде клиента либо конфигурироваться в настройках и, например, храниться в реестре.

Есть и другие варианты сетевой коммуникации — удаленные вызов процедур, или RPC, и очереди MSMQ. Их мы изучим в книге 3, когда будем говорить о способах взаимодействия.

С другой стороны, можно использовать сокетный интерфейс, но без задействования стека ядра. В ОС Windows для этого существует WSD, которого мы коснемся в главе 23.

Сейчас же мы приступим к рассмотрению почтовых ящиков и каналов. Изучим их API и разберем примеры.

Описание протоколов, которые использует описанный ниже API, опубликовано на сайте Microsoft137.

Почтовые ящики или Mailslots

Внимание! Удаленные почтовые ящики отключены в ОС Windows 11 по умолчанию138, так как они работают поверх протокола SMB1, который является небезопасным. В новых приложениях данный API139 использовать не стоит.

Почтовый слот, Mailslot или почтовый ящик — это механизм одностороннего межпроцессного взаимодействия. Любое приложение может отправить сообщение в почтовый ящик, а владелец почтового ящика может получить сообщение.

К e-mail эти «почтовые ящики» не имеют никакого отношения.

Владелец почтового ящика, то есть процесс, который его создал, называется сервером почтового ящика.

Процесс, который записывает сообщение в почтовый ящик, называется клиентом почтового ящика.

Достаточно известная команда обмена сообщениями net send, которая в новых версиях ОС Windows заменена утилитой msg, также использовала для рассылки почтовые ящики с именем \\*\MAILSLOT\Messngr.

Как показано на рис. 17.1, почтовые ящики обеспечивают простой способ рассылки уведомлений процессам.

Рис. 17.1. Рассылка уведомлений почтовыми ящиками нескольким процессам

Сообщения могут отправляться по сети на заданный узел либо на все узлы домена. Адресованные ящикам с некоторым именем, они принимаются всеми процессами в домене, создавшими почтовый ящик с этим именем.

Домен — группа рабочих станций и серверов, которые имеют общее имя.

Сообщения передаются как дейтаграммы, поэтому надежную доставку ящики не обеспечивают. Однако порядок сообщений будет сохраняться: новые сообщения всегда следуют за старыми.

Речь идет о поступивших сообщениях: они ставятся в очередь. Но гарантии, что сообщения будут помещены в нее именно в порядке отправки, нет.

Внимание! Удаленные каналы поддерживают сообщения размером не более 424 байт140.

Технически сообщения каналов могут иметь любой размер, но желательно не больше 64 Кбайт. А при работе в сети MSDN устанавливает ограничения на размер сообщений в 424 байта.

Сообщения менее 425 байт отправляются при помощи UDP-дейтаграмм, а сообщения свыше 426 байт — путем соединения по протоколу SMB. Но эту возможность поддерживают только NT-версии ОС Windows, а старые, не NT, не поддерживают.

Редиректор Windows будет проверять, на какую версию ОС система отправляет сообщение, и если сообщение отправляется на старую версию Windows Me, просто обрежет его размер до 424 байт.

Кроме того, редиректор может не работать с размерами сообщений в 425 и 426 байт. Поэтому при отправке сообщений через почтовые ящики по сети лучше не превышать размер 424 байта, а еще лучше — вообще не использовать для этой задачи почтовые ящики.

Когда сервер создает новый почтовый ящик, он получает его дескриптор. В системе почтовый ящик — это псевдофайл, который находится в памяти, поэтому для работы с ним используется файловый API.

Дескриптор нового почтового ящика используется для чтения сообщений. Чтобы отправить сообщение в почтовый ящик, процесс открывает почтовый ящик по имени.

Именование

Имя почтового ящика должно иметь следующий формат:

\\.\mailslot\[путь\]имя

Например:

\\.\mailslot\data\stream_speed

\\.\mailslot\data\stream_pressure

\\.\mailslot\control\valve_angle

Имена нечувствительны к регистру. Точка означает имя текущего узла. Если почтовый ящик создал процесс на другом узле, при открытии почтового ящика точка заменяется именем узла:

\\имя_узла\mailslot\[путь\]имя

Чтобы отправить сообщение в каждый почтовый ящик с заданным именем, в основном домене используется следующая форма:

\\*\mailslot\[путь\]имя

API почтовых ящиков

Алгоритм работы с ящиками следующий:

1. Создать почтовый ящик с помощью API-функции CreateMailslot().

2. Читать данные, используя функцию ReadFile().

3. Закрыть дескриптор почтового ящика с помощью CloseHandle().

Функция CreateMailslot():

#include <winbase.h>

HANDLE CreateMailslot(

LPCTSTR lpName,

DWORD nMaxMessageSize,

DWORD lReadTimeout,

LPSECURITY_ATTRIBUTES lpSecurityAttributes

);

Ее параметры:

• lpName — путь к ящику.

• nMaxMessageSize — максимальный размер сообщения, которое может быть записано в почтовый ящик. Если сообщение может иметь произвольный размер, значение параметра должно быть равно нулю.

• lReadTimeout — время, в течение которого операция чтения будет ожидать новое сообщение, в миллисекундах.

• lpSecurityAttributes — необязательный указатель на структуру SECURITY_ATTRIBUTES. Ее атрибут bInheritHandle определяет, может ли быть дескриптор унаследован процессами-потомками.

Параметр lReadTimeout имеет два специальных значения, определяющих поведение ReadFile(), если сообщений в ящике нет:

• 0 — возвратиться немедленно.

• MAILSLOT_WAIT_FOREVER — заблокироваться до появления сообщения.

Созданный почтовый ящик полностью реализует семантику файла, и для его открытия клиент использует стандартную WinAPI-функцию CreateFile() с параметром dwDesiredAccess, равным GENERIC_WRITE, — открытие на запись — и параметром dwCreationDisposition, равным OPEN_EXISTING, то есть открывается существующий «файл»:

HANDLE hFile = CreateFile(mail_slot_name,

GENERIC_WRITE,

FILE_SHARE_READ,

nullptr,

OPEN_EXISTING,

FILE_ATTRIBUTE_NORMAL,

nullptr

);

Чтобы использовать почтовые ящики, обычно включают не специфичные файлы, в которых объявлены функции, а сразу заголовочный файл windows.h.

Помимо общего API для работы с хэндлами, такого как GetHandleInfo(), для почтовых ящиков существуют две специфичные функции:

#include <winbase.h>

// Получить информацию о ящике.

bool GetMailslotInfo(

HANDLE hMailslot,

LPDWORD lpMaxMessageSize,

LPDWORD lpNextSize,

LPDWORD lpMessageCount,

LPDWORD lpReadTimeout

);

// Установить параметры ящика.

bool SetMailslotInfo(

HANDLE hMailslot,

DWORD lReadTimeout

);

Их параметры:

• hMailSlot — хэндл почтового ящика.

• lpMaxMessageSize — максимальный размер сообщения в байтах, разрешенный для почтового ящика.

• lpNextSize — размер следующего сообщения в байтах. Если сообщения нет, в переменную будет записано MAILSLOT_NO_MESSAGE.

• lpMessageCount — общее количество сообщений, ожидающих чтения. Может быть nullptr.

• lpReadTimeout — время ожидания функции ReadFile(). Может быть nullptr.

Как вы заметили, переустановить можно только время ожидания чтения.

Через функцию SetHandleInformation() также можно переустановить флаг наследования. Однако общий файловый API — обширная тема, выходящая за рамки этой книги, поэтому мы ее затрагивать не будем.

Максимальный размер сообщения устанавливается только при создании ящика, а остальные параметры изменяются в процессе работы с ящиком, поэтому их можно только получить.

Пример работы с ящиками

На рис. 17.2 показана схема записи сообщений в ящик, производимой через вызовы функции WriteFile(), и чтения полученных данных функцией ReadFile().

Внимание! Максимальный размер сообщения в почтовых слотах — 424 байта. Начиная с Windows XP SP2, можно отправлять 1536 байт. Однако размер сообщения по-прежнему ограничен размером дейтаграммы.

Рис. 17.2. Взаимодействие сервера и клиента в mailslots

Сервер:

int main()

{

// Клиент должен знать это имя, чтобы отправить сообщение в ящик.

const LPCTSTR slot_name = TEXT("\\\\.\\mailslot\\test_mailslot");

// Создать ящик. Работать в блокирующем режиме.

HANDLE h_slot = CreateMailslot(slot_name, 0, MAILSLOT_WAIT_FOREVER,

nullptr);

if (INVALID_HANDLE_VALUE == h_slot)

{

std::cerr << "CreateMailslot failed" << std::endl;

return EXIT_FAILURE;

}

std::cout << "Mailslot created successfully." << std::endl;

std::array<char, 2048> buffer;

DWORD bytes_read;

// Чтение данных из слота.

while (ReadFile(h_slot, &buffer[0], buffer.size(), &bytes_read, nullptr))

{

std::cout

<< "Received: "

<< std::string(buffer.begin(), buffer.begin() + bytes_read)

<< std::endl;

}

// Закрыть почтовый ящик.

CloseHandle(h_slot);

return EXIT_SUCCESS;

}

Клиент:

int main()

{

const LPCTSTR slot_name = TEXT("\\\\.\\mailslot\\test_mailslot");

// Подключиться к почтовому ящику.

HANDLE h_slot = CreateFile(slot_name, GENERIC_WRITE, FILE_SHARE_READ,

nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,

nullptr);

if (INVALID_HANDLE_VALUE == h_slot)

{

std::cerr << "CreateFile failed" << std::endl;

return EXIT_FAILURE;

}

const std::string msg = "Test message...";

DWORD bytes_written = 0;

// Отправить сообщение, используя обычный файловый API.

if (!WriteFile(h_slot, msg.c_str(), msg.size(), &bytes_written, nullptr))

{

std::cerr << "Failed" << std::endl;

CloseHandle(h_slot);

return EXIT_FAILURE;

}

std::cout << "Ok" << std::endl;

CloseHandle(h_slot);

return EXIT_SUCCESS;

}

Именованные каналы

Начиная с Windows 2000, для связи между локальными процессами ОС Windows предоставляет именованные каналы.

Именованный канал — это механизм одностороннего или двустороннего обмена данными между сервером и одним или несколькими клиентами.

Этот API является одним из вариантов замены для устаревшего API удаленных почтовых ящиков.

Так же, как и в случае почтовых ящиков, сервер канала — процесс, который создает именованный канал, клиент канала — процесс, который подключается к экземпляру именованного канала.

Особенностью каналов в ОС Windows является возможность их работы поверх сети. В таком случае они используют TCP, то есть, в отличие от почтовых ящиков, обеспечивают гарантированную надежную доставку неограниченного объема данных.

Локально работающие каналы будут использовать общую память — ОС сама выбирает оптимальный транспорт, если иное не было указано явно.

Именованные каналы доступны удаленно, если запущена служба сервера.

Если именованный канал используется только локально, необходимо запретить доступ к NT_AUTHORITY\NETWORK или переключиться на локальный RPC, для чего достаточно остановить службу Server.

При этом каналы не ограничены потоком данных, они могут передавать и поток сообщений, то есть бывают двух типов:

• Байтовые, в которых даже система не различает границы между данными, записанными разными операциями.

• Ориентированные на сообщения, в которых границы порций данных известны.

По умолчанию канал — это байтовый поток, который работает в блокирующем режиме и принимает удаленные подключения.

Так же как в случае ящиков, клиенты могут одновременно использовать один и тот же именованный канал, просто указав одинаковое имя. Любой процесс может получить доступ к именованным каналам при наличии прав. Все экземпляры такого канала имеют одно и то же имя, одинаковый тип, но свои собственные буферы и дескрипторы.

Каналы могут быть не только однонаправленные, но и двунаправленные, как показано на рис. 17.3.

Рис. 17.3. Двунаправленный канал

Именование

Имена каналов уникальны и нечувствительны к регистру. Как и в случае имен почтовых ящиков, они должны иметь определенный формат:

\\имя_сервера\pipe\имя_канала

Имя сервера — имя удаленного узла или точка, если необходимо указать локальный узел:

\\.\pipe\имя_канала

Сервер каналов не может создавать каналы на другом узле, поэтому для имени сервера должна использоваться точка.

Имя канала может включать любой символ, кроме обратного слеша, и содержит максимум 256 символов.

API именованных каналов

При вызове функции CreateNamedPipe() сервер каналов указывает имя канала для создания одного или нескольких его экземпляров:

#include <winbase.h>

HANDLE CreateNamedPipe(

LPCTSTR lpName,

DWORD dwOpenMode,

DWORD dwPipeMode,

DWORD nMaxInstances,

DWORD nOutBufferSize,

DWORD nInBufferSize,

DWORD nDefaultTimeOut,

LPSECURITY_ATTRIBUTES lpSecurityAttributes

);

Параметры функции CreateNamedPipe():

• lpName — уникальное название канала, например \\.\pipe\my_pipename.

• dwOpenMode — флаги открытия канала:

• PIPE_ACCESS_DUPLEX — канал двунаправленный. Как серверные, так и клиентские процессы могут читать и записывать в него.

• PIPE_ACCESS_INBOUND — поток данных в канале идет только от клиента к серверу.

• PIPE_ACCESS_OUTBOUND — поток данных в канале идет только от сервера к клиенту.

• dwPipeMode — режим канала:

• PIPE_TYPE_BYTE или PIPE_TYPE_MESSAGE — режим записи. Байтовый поток или сообщения.

• PIPE_READMODE_BYTE или PIPE_READMODE_MESSAGE — режим чтения. Байтовый поток или сообщения.

• PIPE_WAIT или PIPE_NOWAIT — блокирующий или неблокирующий режим.

• PIPE_ACCEPT_REMOTE_CLIENTS или PIPE_REJECT_REMOTE_CLIENTS — принимать подключения от удаленных клиентов или отбрасывать.

• nMaxInstances — максимальное количество экземпляров, которые можно создать для этого канала. Значение может быть указано в первом экземпляре канала и должно быть указано для всех других экземпляров. Допустимые значения находятся в диапазоне от 1 до PIPE_UNLIMITED_INSTANCES, то есть неограниченного количества экземпляров.

• nOutBufferSize — количество байтов в буфере отправки.

• nInBufferSize — количество байтов в буфере приема.

• nDefaultTimeout — время ожидания в миллисекундах. Должно быть одинаково у каждого экземпляра. По умолчанию — 50 мс.

• lpSecurityAttributes — необязательный указатель на параметры безопасности — структуру SECURITY_ATTRIBUTES.

Параметр dwOpenMode может дополнительно включать следующие флаги:

• FILE_FLAG_FIRST_PIPE_INSTANCE — если попытаться создать несколько экземпляров канала с этим флагом, создание первого экземпляра завершится успешно, а попытка создания других экземпляров будет завершаться с ошибкой ERROR_ACCESS_DENIED.

• FILE_FLAG_WRITE_THROUGH — включить режим сквозной записи. В этом режиме клиентский и серверный процессы на разных компьютерах буферизуют операции записи в байтовых каналах. Буфер может опустошаться, если достигнут минимальный размер данных либо истек тайм-аут.

• FILE_FLAG_OVERLAPPED — будет использован перекрывающийся ввод-вывод.

Параметры безопасности также являются частью режима открытия, но здесь мы их рассматривать не будем: они представляют собой общую часть WinAPI.

Функция возвращает дескриптор канала или INVALID_HANDLE.

Как и при использовании ящиков, клиентский процесс может подключиться к именованному каналу с помощью функции CreateFile():

#include <fileapi.h>

...

HANDLE h_pipe = CreateFile(TEXT(R"(\\.\pipe\test_pipe)"),

GENERIC_READ | GENERIC_WRITE,

0, nullptr, OPEN_EXISTING, 0, nullptr);

Логика ее вызова достаточно своеобразная:

1. Если функция возвращает корректный хэндл, значит, канал подключен.

2. Когда функция вернет INVALID_HANDLE_VALUE, необходимо проверить код ошибки посредством вызова GetLastError().

3. Код, равный ERROR_PIPE_BUSY, говорит о том, что канал занят и требуется вызвать функцию WaitNamedPipe(), описанную ниже.

4. Если функция вернет истину, можно повторить процесс подключения.

Функция на стороне сервера, ожидающая и принимающая входящие соединения, — ConnectNamedPipe().

Для разрыва соединения используется функция DisconnectNamedPipe():

#include <namedpipeapi.h>

// Подключиться к существующему каналу.

bool ConnectNamedPipe(HANDLE hNamedPipe, LPOVERLAPPED lpOverlapped);

// Разорвать соединение.

bool DisconnectNamedPipe(HANDLE hNamedPipe);

Ее параметры:

• hNamedPipe — дескриптор канала.

• lpOverlapped — необязательный указатель на структуру для выполнения перекрывающегося ввода-вывода.

Следующая функция позволяет ожидать, когда подключение будет выполнено или переданный тайм-аут истечет:

bool WaitNamedPipe(LPCTSTR lpNamedPipeName, DWORD nTimeOut);

В случае успеха функции вернут истину, в противном случае установят код ошибки, например ERROR_SEM_TIMEOUT при истечении тайм-аута.

Возможно одновременно использовать несколько экземпляров канала и работать с ними несколькими способами:

• Создать отдельный поток для каждого экземпляра канала. Пример — многопоточный конвейерный сервер.

• Использовать перекрывающиеся операции, указав структуру OVERLAPPED в функциях ReadFile(), WriteFile() и ConnectNamedPipe().

• Использовать перекрывающиеся операции, вызывая функции ReadFileEx() и WriteFileEx(), которые определяют процедуру, выполняемую после завершения операции.

Когда клиент и сервер завершают использование экземпляра канала, сервер должен вызвать функцию FlushFileBuffers(), чтобы убедиться, что все байты или сообщения, записанные в канал, прочитаны клиентом.

Затем сервер вызывает функцию DisconnectNamedPipe() для закрытия соединения с клиентом. Функция сделает дескриптор клиента недействительным, если он еще не был закрыт. Любые непрочитанные данные в канале будут отброшены. Когда отключится клиент, сервер может вызвать функцию CloseHandle(), чтобы закрыть свой дескриптор экземпляра канала, либо, наоборот, снова вызвать ConnectNamedPipe(), чтобы разрешить новому клиенту подключиться к экземпляру канала.

Процесс может получить тип канала, размер входного и выходного буферов и максимальное количество экземпляров канала, используя функцию GetNamedPipeInfo():

#include <namedpipeapi.h>

bool GetNamedPipeInfo(

HANDLE hNamedPipe,

LPDWORD lpFlags,

LPDWORD lpOutBufferSize,

LPDWORD lpInBufferSize,

LPDWORD lpMaxInstances

);

Ее параметры:

• hNamedPipe — хэндл канала.

• lpFlags — указатель на переменную, которая получит тип именованного канала. Может быть нулевым. В противном случае может принимать комбинацию следующих значений:

• PIPE_CLIENT_END или PIPE_SERVER_END — канал подключен как клиент или как сервер.

• PIPE_TYPE_BYTE или PIPE_TYPE_MESSAGE — канал байтовый или ориентирован на сообщения.

• lpOutBufferSize — указатель на переменную, в которую будет записано количество байтов в буфере исходящих данных.

• lpInBufferSize — указатель на переменную, в которую будет записано количество байтов в буфере входящих данных.

• lpMaxInstances — указатель на переменную, в которую будет записано максимальное количество экземпляров канала, которые можно создать. Значение PIPE_UNLIMITED_INSTANCES означает неограниченное количество.

Функции GetNamedPipeHandleState() и SetNamedPipeHandleState() позволяют узнать либо установить режим чтения и ожидания канала, а также дополнительную информацию:

#include <namedpipeapi.h>

bool GetNamedPipeHandleState(

HANDLE hNamedPipe,

LPDWORD lpState,

LPDWORD lpCurInstances,

LPDWORD lpMaxCollectionCount,

LPDWORD lpCollectDataTimeout,

LPWSTR lpUserName,

DWORD nMaxUserNameSize

);

bool SetNamedPipeHandleState(

HANDLE hNamedPipe,

LPDWORD lpMode,

LPDWORD lpMaxCollectionCount,

LPDWORD lpCollectDataTimeout

);

Параметры указанных функций:

• hNamedPipe — хэндл канала.

• lpState — указатель на переменную, содержащую текущее состояние канала:

• PIPE_NOWAIT — канал в неблокирующем режиме.

• PIPE_READMODE_MESSAGE — канал находится в режиме чтения сообщений, а не байтов из потока.

• lpMode — указатель на комбинацию флага режима чтения и флага режима ожидания:

• PIPE_READMODE_BYTE или PIPE_READMODE_MESSAGE — данные читаются как поток байтов или поток сообщений. Последний может быть включен только для каналов обмена сообщениями.

• PIPE_WAIT или PIPE_NOWAIT — включение блокирующего или неблокирующего режима.

• lpCurInstances — указатель на переменную, получающую количество экземпляров канала.

• lpMaxCollectionCount — указатель на переменную, содержащую максимальное количество байтов, которые буферизуются клиентом перед отправкой на сервер.

• lpCollectDataTimeout — указатель на переменную, содержащую максимальное количество миллисекунд до передачи данных по сети.

• lpUserName — указатель на буфер с именем пользователя.

• nMaxUserNameSize — размер буфера lpUserName.

Для обмена данными используются функции ReadFile() и WriteFile(), а также прочие функции из состава файлового API.

Эффективный обмен можно производить через вызов функции TransactNamedPipe(), которая отправляет данные, после чего сразу принимает ответ:

#include <namedpipeapi.h>

bool TransactNamedPipe(

HANDLE hNamedPipe,

void* lpInBuffer,

DWORD nInBufferSize,

void* lpOutBuffer,

DWORD nOutBufferSize,

LPDWORD lpBytesRead,

LPOVERLAPPED lpOverlapped

);

Параметры функции TransactNamedPipe():

• hNamedPipe — хэндл канала.

• lpInBuffer — буфер, содержащий данные, записываемые в канал.

• nInBufferSize — размер буфера записываемых данных.

• lpOutBuffer — буфер, принимающий данные ответа из канала.

• nOutBufferSize — размер буфера, принимающего данные.

• lpBytesRead — указатель на переменную, в которой будет сохранено количество байтов, прочитанных из канала. Актуален, только если не используется перекрывающийся ввод-вывод.

• lpOverlapped — указатель на структуру OVERLAPPED. Требуется, если канал был открыт с флагом FILE_FLAG_OVERLAPPED.

Функция CallNamedPipe() еще сильнее упрощает работу с каналом:

#include <winbase.h>

bool CallNamedPipe(

LPCTSTR lpNamedPipeName,

LPVOID lpInBuffer,

DWORD nInBufferSize,

LPVOID lpOutBuffer,

DWORD nOutBufferSize,

LPDWORD lpBytesRead,

DWORD nTimeOut

);

Параметры функции CallNamedPipe():

• lpNamedPipeName — имя канала.

• lpInBuffer — данные для записи в канал.

• nInBufferSize — размер буфера записи.

• lpOutBuffer — указатель на буфер, который получает данные, считанные из канала.

• lpBytesRead — указатель на переменную, получающую число байтов, прочитанных из канала.

• nTimeOut — ожидание доступности канала в миллисекундах. Специальные значения — NMPWAIT_USE_DEFAULT_WAIT для ожидания по умолчанию или NMPWAIT_WAIT_FOREVER для бесконечного ожидания.

Она создаст канал, отправит данные и примет ответ. Если операция выполнена до истечения тайм-аута, функция вернет истину.

Следующая функция позволяет читать данные в буфер, не удаляя их оттуда:

#include <namedpipeapi.h>

bool PeekNamedPipe(

HANDLE hNamedPipe,

void* lpBuffer,

DWORD nBufferSize,

LPDWORD lpBytesRead,

LPDWORD lpTotalBytesAvail,

LPDWORD lpBytesLeftThisMessage

);

Ее параметры:

• hNamedPipe — хэндл канала.

• lpBuffer — буфер, в который будут прочитаны данные.

• nBufferSize — размер буфера.

• lpBytesRead — указатель на число прочитанных байтов.

• lpTotalBytesAvail — общее количество байтов, которые могут быть прочитаны из буфера.

• lpBytesLeftThisMessage — указатель на переменную, получающую количество байтов, оставшихся в этом сообщении.

Возвращаемый результат — истина в случае успеха, в противном случае — ложь. Функция может быть полезна для реализации вышележащих протоколов, например, чтобы прочитать заголовок без его удаления.

На рис. 17.4 показан процесс работы с каналом. Сначала он создается на сервере, который ожидает подключений. Когда подключился клиент, начинается обмен данными, который завершается отключением сторон. Серверный цикл ожидания подключений будет продолжаться, чтобы принимать новые клиенты.

Если канал больше не требуется, его дескриптор необходимо закрыть, вызвав функцию CloseHandle().

Рис. 17.4. Процесс работы с каналом

Пример работы с именованными каналами на C++

Рассмотрим, как работать с каналом, на примере. Для начала реализуем небольшую функцию, чтобы получить размер элемента контейнера:

// Хелпер для получения размера элемента контейнера.

template <typename T>

constexpr auto ElementSize(const T&)

{

return sizeof(T::value_type);

}

Эта функция необходима потому, что канал оперирует не символами, которые могут быть широкими, а байтами.

Сервер начинается с создания нового именованного канала:

int main(int argc, char **argv)

{

HANDLE h_pipe;

using tstring = std::basic_string<TCHAR,

std::char_traits<TCHAR>, std::allocator<TCHAR>>;

const tstring message = TEXT("I'm server: ");

std::array<TCHAR, 512> read_buffer;

while (true)

{

static const tstring pipe_name = TEXT(R"(\\.\pipe\test_pipe)");

// Создать именованный канал.

h_pipe = CreateNamedPipe(pipe_name.c_str(), PIPE_ACCESS_DUPLEX,

PIPE_TYPE_MESSAGE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES,

message.size() * ElementSize(message),

read_buffer.size() * ElementSize(message),

NMPWAIT_USE_DEFAULT_WAIT, nullptr);

// Канал удалось создать.

if (h_pipe != INVALID_HANDLE_VALUE) break;

// Если код ошибки не ERROR_PIPE_BUSY, завершиться.

if (GetLastError() != ERROR_PIPE_BUSY)

{

std::cerr << "Could not open pipe." << std::endl;

return EXIT_FAILURE;

}

}

Канал предпочтительнее создавать в цикле, ведь если экземпляров канала создано достаточно много, функция GetLastError() вернет ошибку ERROR_PIPE_BUSY. В этом случае попытку создания необходимо повторить. Сказанное справедливо и при открытии канала на клиенте, но его мы намеренно упростили.

Созданный канал позволяет многократно переиспользовать свой дескриптор. Ожидать подключений будет вызов функции ConnectNamedPipe():

while (true)

{

std::cout << "Waiting for connections..." << std::endl;

// Ожидать соединений от клиента.

if (!ConnectNamedPipe(h_pipe, nullptr))

{

std::cerr << "Could not connect pipe" << std::endl;

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

std::cout << "Pipe connected, reading data..." << std::endl;

// Канал подключен.

// Изменить режим канала на режим чтения сообщений.

DWORD mode = PIPE_READMODE_MESSAGE;

if (!SetNamedPipeHandleState(h_pipe, &mode, nullptr, nullptr))

{

std::cerr << "SetNamedPipeHandleState() failed." << std::endl;

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

Делается это в цикле, и когда клиент отключится, может подключиться другой. Режим можно не менять, установив его сразу при создании канала.

Вызов этой функции мы показали для примера.

Данные из канала прочитаем, используя функцию ReadFile() из «обычного» файлового API:

// Количество байтов, прочитанных из канала.

DWORD read_bytes = 0;

// Прочитать данные из канала.

if (!ReadFile(h_pipe, read_buffer.data(),

read_buffer.size() * ElementSize(read_buffer),

&read_bytes, nullptr)

&& (GetLastError() != ERROR_MORE_DATA))

{

// Выйти, если ошибка не ERROR_MORE_DATA.

DisconnectNamedPipe(h_pipe);

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

else

{

std::cout

<< "Data read: "

<< std::string(read_buffer.begin(),

read_buffer.begin() + read_bytes)

<< std::endl;

}

// Количество прочитанных байтов используется, чтобы выделить

// порцию данных в буфере.

const auto new_msg = message +

tstring(read_buffer.begin(), read_buffer.begin() + read_bytes);

Отправка данных выполняется функцией WriteFile():

std::cout << "Sending data..." << std::endl;

// Количество записанных байтов.

DWORD written_bytes = 0;

// Отправить данные в канал.

if (!WriteFile(h_pipe, new_msg.c_str(),

new_msg.size() * ElementSize(new_msg), &written_bytes,

nullptr))

{

std::cout << "Failed to send data." << std::endl;

DisconnectNamedPipe(h_pipe);

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

std::cout << "Bytes sent: " << written_bytes << std::endl;

После этого упрощенный сервер завершает общение с клиентом — отправляет неотправленные данные и разрывает соединение:

// Отправить неотправленные данные.

if (!FlushFileBuffers(h_pipe))

{

std::cerr << "FlushFileBuffers() failed." << std::endl;

DisconnectNamedPipe(h_pipe);

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

// Отключить соединение.

if (!DisconnectNamedPipe(h_pipe))

{

std::cerr << "Could not disconnect pipe." << std::endl;

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

}

CloseHandle(h_pipe);

return EXIT_SUCCESS;

}

Клиент открывает канал, предоставленный сервером, и устанавливает его режим:

int main(int argc, char** argv)

{

using tstring = std::basic_string<TCHAR, std::char_traits<TCHAR>,

std::allocator<TCHAR>>;

tstring message = TEXT("I'm message from client");

if (argc > 1)

{

message = argv[1];

}

const tstring pipe_name = TEXT(R"(\\.\pipe\test_pipe)");

// Открыть именованный канал.

const HANDLE h_pipe = CreateFile(pipe_name.c_str(),

GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);

if (INVALID_HANDLE_VALUE == h_pipe)

{

std::cerr << "Pipe creating error!" << std::endl;

return EXIT_FAILURE;

}

DWORD mode = PIPE_READMODE_MESSAGE;

// Установить канал в режим чтения сообщений.

if (!SetNamedPipeHandleState(h_pipe, &mode, nullptr, nullptr))

{

std::cerr << "SetNamedPipeHandleState failed." << std::endl;

CloseHandle(h_pipe);

return EXIT_FAILURE;

}

Обмен данными клиент будет осуществлять, используя функцию TransactNamedPipe():

// Количество прочитанных из канала байтов.

DWORD read_bytes;

// Отправить в канал сообщение и прочитать ответ.

if (!TransactNamedPipe(h_pipe,

const_cast<TCHAR*>(message.c_str()),

message.size() * ElementSize(message),

read_buffer.data(),

read_buffer.size() * ElementSize(read_buffer),

&read_bytes, nullptr)

&& (GetLastError() != ERROR_MORE_DATA))

{

CloseHandle(h_pipe);

std::cerr << "TransactNamedPipe failed." << std::endl;

return EXIT_FAILURE;

}

else

{

std::cout

<< "Data: "

<< std::string(read_buffer.begin(),

read_buffer.begin() + read_bytes)

<< std::endl;

}

CloseHandle(h_pipe);

return EXIT_SUCCESS;

}

Сначала запустим сервер, а затем клиент, отправляющий данные серверу, который вернет их клиенту. Поэтому на стороне клиента будут выведены данные, которые отправил клиент:

D:\build\bin> b01-ch17-named-pipe-client.exe

Data: I'm server: I'm message from client

D:\build\bin> b01-ch17-named-pipe-client.exe test

Data: I'm server: test

Сервер выведет данные, принятые от клиента:

D:\build\bin> b01-ch17-named-pipe-server.exe

Waiting for the connections...

Pipe connected, reading data...

Data read: I'm message from client

Sending data...

Bytes sent: 35

Waiting for the connections...

Pipe connected, reading data...

Data read: test

Sending data...

Bytes sent: 16

Waiting for the connections...

Реализовать клиент можно еще проще, парой десятков строчек, если использовать функцию CallNamedPipe(), которая откроет канал и выполнит обмен данными:

if (!CallNamedPipe(pipe_name.c_str(),

const_cast<TCHAR*>(message.c_str()),

message.size() * ElementSize(message),

read_buffer.data(),

read_buffer.size() * ElementSize(read_buffer),

&read_bytes, 0))

{

std::cerr << "CallNamedPipe failed." << std::endl;

return EXIT_FAILURE;

}

else

{

std::cout

<< "Data: "

<< std::string(read_buffer.begin(), read_buffer.begin() + read_bytes)

<< std::endl;

}

Пример работы с именованными каналами на Python

Перепишем на Python реализованный на C++ сервер именованных каналов, используя pywin32.

Создадим канал:

import sys

from win32.win32api import GetLastError

from win32.win32file import ReadFile, WriteFile, CloseHandle, FlushFileBuffers

from win32.win32pipe import *

from winerror import ERROR_MORE_DATA, ERROR_PIPE_BUSY

h_pipe = None

message = "I'm server: "

pipe_name = r'\\.\pipe\test_pipe'

buf_size = 1024

# Попытаться открыть канал.

while True:

# Создать именованный канал.

h_pipe = CreateNamedPipe(pipe_name, PIPE_ACCESS_DUPLEX,

PIPE_TYPE_MESSAGE | PIPE_WAIT,

PIPE_UNLIMITED_INSTANCES,

len(message) * 2, buf_size,

NMPWAIT_USE_DEFAULT_WAIT, None)

# Канал удалось создать.

if h_pipe:

break

# Если код ошибки не ERROR_PIPE_BUSY, завершиться.

if GetLastError() != win32api.ERROR_PIPE_BUSY:

print('Could not open pipe.')

sys.exit(1)

Будем ожидать подключений от клиентов:

try:

while True:

print('Waiting for connections...')

try:

# Ожидать соединений от клиента.

ConnectNamedPipe(h_pipe, None)

except OSError:

print('Could not connect pipe')

sys.exit(1)

print('Pipe connected, reading data...')

# Канал подключен. Изменить режим канала на режим чтения сообщений.

try:

SetNamedPipeHandleState(h_pipe, PIPE_READMODE_MESSAGE, None, None)

except BaseException:

print('SetNamedPipeHandleState() failed')

sys.exit(1)

Затем, когда клиент подключился, прочитаем от него данные и отправим ему ответ, после чего отключим соединение:

# Прочитать данные из канала.

result, data = ReadFile(h_pipe, buf_size)

# Выйти, если ошибка не ERROR_MORE_DATA.

if result and GetLastError() != ERROR_MORE_DATA:

DisconnectNamedPipe(h_pipe)

sys.exit(1)

else:

print(f'Data read: {data}')

new_msg = f'{message}{data.decode()}'.encode()

print('Sending data...')

# Отправить данные в канал.

err_code, written_bytes = WriteFile(h_pipe, new_msg)

if not err_code and written_bytes:

print(f'Bytes sent: {written_bytes}')

else:

print('Failed to send data.')

sys.exit(1)

# Отправить неотправленные данные.

if FlushFileBuffers(h_pipe):

print('FlushFileBuffers() failed.')

sys.exit(1)

# Отключить соединение.

if DisconnectNamedPipe(h_pipe):

print('Could not disconnect pipe.')

sys.exit(1)

finally:

CloseHandle(h_pipe)

Видим, что код практически такой же, как и на C++. Запустим пример и подключимся к нему, используя клиент C++, реализованный ранее:

D:\network-programming-book-code> py src\book01\ch17\python

\pywin32_namedpipe_server.py

Waiting for connections...

Pipe connected, reading data...

Data read: b"I'm message from client"

Sending data...

Bytes sent: 35

Waiting for connections...

Pipe connected, reading data...

Data read: b"I'm message from client"

Sending data...

Bytes sent: 35

Waiting for connections...

Результат такой же, как и при использовании сервера C++.

Почтовые ящики и каналы — достаточно простой API для межпроцессного взаимодействия по сети. Но использовать их при сетевом обмене — не всегда хорошая практика. Они специфичны для ОС Windows и в случае кросс-платформенных приложений требуют выноса кода для Windows в отдельные модули, а также разработчиков с опытом использования WinAPI, чтобы этот код поддерживать. Кроме того, данный API не обеспечивает такого полного контроля, как сокеты.

Резюме

ОС Windows предоставляет несколько высокоуровневых способов межпроцессного взаимодействия, таких как почтовые ящики и именованные каналы. Их также возможно использовать поверх сети.

Почтовый ящик представляет собой механизм одностороннего взаимодействия между процессами на одной машине или в локальной сети.

Любое приложение может отправить сообщение в почтовый ящик, а владелец почтового ящика может получить это сообщение.

Почтовые ящики работают поверх UDP и не обеспечивают гарантированной доставки. Использование почтовых ящиков подходит для ситуаций, когда приложениям требуется быстрая отправка сообщений без гарантий.

Именованный канал — это примитив, обеспечивающий байтовый поток, который принимает удаленные подключения.

Каналы обычно двунаправленные и работают поверх TCP, то есть, в отличие от почтовых ящиков, обеспечивают возможность гарантированной надежной доставки неограниченного объема данных.

Каждый из этих инструментов для межпроцессного взаимодействия имеет свои особенности и преимущества в зависимости от требований конкретного приложения.

Вопросы и задания

1. Что такое почтовые ящики и как они работают?

2. Каким образом формируются имена почтовых ящиков?

3. Как создать почтовый ящик на сервере?

4. Как обратиться к почтовому ящику с клиента?

5. Каким образом при использовании почтовых ящиков сервер может ответить на сообщение клиента?

6. Какую информацию о почтовом ящике можно получить? Какие функции для этого используются?

7. Чем ограничен размер сообщения, которое может быть отправлено в почтовый ящик?

8. Можно ли гарантировать надежную доставку сообщений при использовании почтовых ящиков? А при использовании именованных каналов?

9. Что такое именованные каналы и как они работают?

10. Каким образом формируются имена каналов?

11. Какие существуют типы именованных каналов?

12. Как создать именованный канал на сервере?

13. Как осуществляется подключение и обмен данными между клиентом и сервером при использовании именованных каналов?

14. Могут ли клиенты одновременно использовать один и тот же именованный канал?

15. О чем говорит ошибка ERROR_PIPE_BUSY при вызове CreateFile() для открытия именованного канала? Что необходимо сделать в этом случае?

16. Как получить данные из буфера канала, не удаляя их оттуда?

17. С помощью какой функции на клиенте можно за один раз выполнить подключение, обмен данными по именованному каналу и отключение? Какой функцией ее лучше заменить, если клиент выполняет обмен данными многократно?

18. В примере обмена данными с помощью почтового ящика мы отправили текстовое сообщение. Доработайте клиент и сервер таким образом, чтобы клиент мог передать файл через почтовый ящик.

19. Доработайте клиент и сервер в примере, где для передачи файла используются именованные каналы.

137 https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/d416ff7c-c536-406e-a951-4f04b2fd1d2b

138 https://blogs.windows.com/windows-insider/2023/03/08/announcing-windows-11-insider-preview-build-25314/

139 https://techcommunity.microsoft.com/t5/storage-at-microsoft/the-beginning-of-the-end-of-remote-mailslots-as-part-of-windows/ba-p/3762048

140 https://learn.microsoft.com/en-us/windows/win32/ipc/about-mailslots

Глава 18. Управление сетью в ОС Windows

Если вы думаете, что это просто, значит, вы неправильно поняли проблему.

Бьерн Страуструп, «FAQ: Did you really say that?», 2007

Введение

В этой главе мы сосредоточим внимание на параметрах сокетов, устройств и интерфейсов в ОС Windows.

Система предоставляет разнообразные API для управления поведением стека, что позволяет разработчикам создавать гибкие и эффективные сетевые приложения под различные потребности.

Так же как и в POSIX-системах, различными атрибутами сокета можно управлять, используя функции getsockopt() и setsockopt(). Опции сокетов разделяются по уровням.

Кроме того, существует набор вызовов ioctl. Они позволяют управлять дескриптором, уведомлениями, сетевыми интерфейсами, настройками протоколов и подсистем, портами и многим другим.

В дополнение к вызовам ioctl существуют функции для управления параметрами драйверов сетевых устройств и самими устройствами, а также дескрипторами.

Для удобства работы в API Windows предоставляются функции-обертки над опциями. В этой главе мы рассмотрим несколько примеров с использованием функций-оберток и расскажем, как этими функциями пользоваться.

Так же как и в POSIX API, в WinSock есть возможность работать со вспомогательными данными, которые используются с некоторыми параметрами сокетов и протоколов.

Данную главу можно использовать в качестве справочника при дальнейшей работе с книгой.

Функции-обертки над опциями

WinSock предоставляет удобные функции-обертки, которые облегчают работу с часто используемыми параметрами. Большая часть из таких функций объявлена в заголовочном файле ws2tcpip.h.

Для чего нужны обертки, хорошо видно на примере функции установки MTU:

int WSASetIPUserMtu(SOCKET Socket, DWORD Mtu);

{

WSAPROTOCOL_INFOW Info;

int InfoSize = sizeof(Info);

int Error;

// Получить информацию о сокете.

Error = getsockopt(Socket, SOL_SOCKET, SO_PROTOCOL_INFO, (PCHAR)&Info,

&InfoSize);

if (Error != SOCKET_ERROR)

{

// Определить, какое семейство адресов использует сокет.

if (Info.iAddressFamily == AF_INET)

{

// Установить MTU для IPv4-сокета.

Error = setsockopt(Socket, IPPROTO_IP, IP_USER_MTU,

(PCHAR)&Mtu, sizeof(Mtu));

#if(_WIN32_WINNT >= 0x0501)

}

else if (Info.iAddressFamily == AF_INET6)

{

// Установить MTU для IPv6-сокета.

Error = setsockopt(Socket, IPPROTO_IPV6, IPV6_USER_MTU,

(PCHAR)&Mtu, sizeof(Mtu));

#endif //(_WIN32_WINNT >= 0x0501)

}

else

{

Error = SOCKET_ERROR;

// Установить ошибку, если тип сокета не поддерживается.

WSASetLastError(WSAEAFNOSUPPORT);

}

}

return Error;

}

Функция универсальная: она работает верно, несмотря на некоторую избыточность: проверяет тип сокета, устанавливает системную ошибку, если он не поддерживается, и т.д.

С другой стороны, существуют функции, которые просто вызывают тот же setsockopt(), но с правильно заданными параметрами.

Например:

int WSASetFailConnectOnIcmpError(SOCKET Socket, DWORD Enabled);

{

return setsockopt(Socket, IPPROTO_TCP, TCP_FAIL_CONNECT_ON_ICMP_ERROR,

(CHAR*)&Enabled, sizeof(Enabled));

}

Большинство функций возвращают код ошибки getsockopt() либо WSAIoctl(). Иногда — SOCKET_ERROR, если операция не поддерживается сокетом.

Использовать данные функции или нет, зависит от конкретного сценария. Если требуется реализовать кросс-платформенный или более эффективный код, иногда лучше напрямую работать с опциями или ioctl. С другой стороны, функции-обертки могут упростить работу с параметрами сокета в ОС Windows, ускоряют разработку и сокращают объем кода.

Некоторые из функций-оберток мы будем рассматривать вместе с опциями и ioctl, которые они используют.

Получение и установка MTU

Функции WSAGetIPUserMtu() и WSASetIPUserMtu() позволяют получить и задать значение пользовательского MTU, то есть максимальное количество байтов, которое может быть передано в одном IP-пакете без фрагментации:

int WSAGetIPUserMtu(SOCKET Socket, DWORD *Mtu);

int WSASetIPUserMtu(SOCKET Socket, DWORD Mtu);

Параметры функций:

• Socket — дескриптор сокета.

• Mtu — указатель или значение MTU.

Функции возвращают 0 в случае успеха или код ошибки в случае неудачи.

Проверка на исполнение блокирующего вызова

Функция WSAIsBlocking() позволяет определить, выполняется ли в данный момент блокирующий вызов Windows Sockets 1.1:

bool WSAIsBlocking();

Данная функция устарела.

Управление сокетами

POSIX-совместимые функции getsockopt() и setsockopt(), которые мы рассматривали в главе 8, присутствуют и в ОС Windows.

Функций ioctl() и fcntl() в ОС Windows нет. Их заменяют функции ioctlsocket() и WSAIoctl().

Кроме того, существует несколько функций, управляющих дескрипторами. Но сначала рассмотрим функции, управляющие сокетами.

Функции setsockopt() и getsockopt()

Прототипы функций получения и установки опций:

#include <winsock2.h>

#include <Mswsock.h>

int WSAAPI getsockopt(

SOCKET s,

int level,

int optname,

char *optval,

int *optlen

);

int WSAAPI setsockopt(

SOCKET s,

int level,

int optname,

const char *optval,

int optlen

);

Видно, что типы аргументов отличаются: в POSIX опция является указателем на void, а ее размер имеет тип socklen_t.

Функции возвращают 0 в случае успеха и SOCKET_ERROR в случае ошибки.

Названия констант для параметра level те же, что и в POSIX. Например, для уровня сокета это SOL_SOCKET.

Мы не будем рассматривать опции уровней IPPROTO_RM, начинающихся с RM_, NSPROTO_IPX, начинающихся с IPX_, SOL_APPLETALK, SOL_IRLMP и подобные. Большая часть опций рассмотрена в главе 8, а некоторые — в главах, где эти константы используются.

Полный список уровней также содержится в MSDN, в подразделе Socket Options в WinSock 2:

https://learn.microsoft.com/en-us/windows/win32/winsock/socket-options

Внимание! Далее рассмотрены только опции, специфичные для ОС Windows. Про общие для обеих систем опции читайте в главе 8 либо в MSDN.

Уровень сокета

Для работы с опциями, которые описаны ниже, параметр level должен быть равен SOL_SOCKET.

В MSDN эти опции легко найти по такому заголовку, как SOL_SOCKET Socket Options: https://docs.microsoft.com/ru-ru/windows/win32/winsock/sol-socket-socket-options.

Опции только для чтения:

• SO_BSP_STATE — получить локальный адрес, локальный порт, удаленный адрес, удаленный порт, тип сокета и протокол, используемый сокетом. Параметр опции — указатель на структуру CSADDR_INFO, буквально содержащую те параметры, которые опция возвращает.

• SO_CONNECT_TIME — количество секунд, в течение которых сокет был подключен. Используется для сокетов, ориентированных на соединение. Сервер может проверить время подключения клиентских сокетов, ожидающих AcceptEx(), чтобы предотвратить атаку на перенасыщение ожидающими соединениями: если соединение «висит» слишком долго, оно просто может быть отключено.

• SO_PROTOCOL_INFO — возвращает структуру WSAPROTOCOL_INFO, которая содержит домен, тип, протокол сокета, GUID используемого сервис-провайдера, флаги, определяющие поведение сокета, и многие другие его параметры.

• SO_PROTOCOL_INFOA и SO_PROTOCOL_INFOW — то же, что SO_PROTOCOL_INFO, но возвращают структуры WSAPROTOCOL_INFOA и WSAPROTOCOL_INFOW соответственно.

Опции прослушивающего сокета:

• SO_PAUSE_ACCEPT — если опция установлена, сокет перестанет отвечать на входящие соединения, отправляя RST.

• SO_OPENTYPE — влияет на то, будут ли последующие создаваемые сокеты использовать overlapped API. Флаг для чтения и записи. Возможные значения:

• SO_SYNCHRONOUS_ALERT — использовать перекрывающийся режим.

• SO_SYNCHRONOUS_NONALERT — не использовать перекрывающийся режим.

Внимание! Вместо опции SO_OPENTYPE, которая устарела, используйте функцию WSASocket() и бит WSA_FLAG_OVERLAPPED в параметре dwFlags.

Опции управления портами:

• SO_REUSE_MULTICASTPORT — если этот флаг установлен, сокет не будет использоваться для получения одноадресных пакетов, то есть его порт можно будет совместно использовать с другими приложениями, предназначенными только для многоадресной рассылки.

• SO_REUSE_UNICASTPORT — разрешить повторное использование эфемерного порта для функций с явной привязкой к порту типа ConnectEx(). Для функций с неявной привязкой, например connect(), этот параметр установлен по умолчанию.

• SO_PORT_SCALABILITY — при включении для каждого из локальных IP-адресов можно использовать по 64 тысячи портов. Полезно для прокси-серверов, которые могут быстро исчерпать локальные порты. Чтобы решить проблему, к машине можно добавить IP-адреса. По умолчанию порты с подстановочными знаками, используемые bind(), ограничены размером диапазона портов на все IP-адреса: до 64 тысяч портов, а обычно меньше. На платформах, где доступны оба параметра, вместо этого параметра рекомендуется использовать SO_REUSE_UNICASTPORT.

Если установить большое число TCP-соединений, исчерпаются порты и для следующих подключений система вернет ошибку WSAENOBUFS. Хотя жесткий лимит — 64 тысячи портов, обычно это не более 16 тысяч.

Один вариант — привязать пространство портов к IP-адресу, что и делает опция SO_PORT_SCALABILITY, другой — использовать функции установки соединения, требующие явной привязки адреса. В случае обычного вызова bind(), показанного на рис. 18.1, порт и адрес сразу присваиваются сокету.

Рис. 18.1. Обычное поведение функции bind()

При использовании таких функций, как, например, ConnectEx(), которую мы рассмотрим в книге 2, и включенной опции SO_REUSE_UNICASTPORT эфемерный порт выделяется не в момент вызова bind(), а в момент подключения, то есть вызова функции ConnectEx(), как показано на рис. 18.2.

Рис. 18.2. Присвоение адреса при включенной опции SO_REUSE_UNICAST_PORT

Опции для управления приемом клиентов и обновлением параметров сокета:

• SO_CONDITIONAL_ACCEPT — разрешить принимать или отклонять входящие подключения приложением, а не сетевым стеком. Опция весьма полезна для некоторых серверов. Если она включена, соединение не подтверждается, пока приложение не вернет управление из обработчика, автоматически вызываемого на каждое подключение. В случае, если приложение сделало вызов WSAAccept(), обработчик или функция условного принятия должны вернуть CF_ACCEPT, чтобы подтвердить соединение, или CF_REJECT, чтобы отклонить его. Если приложение не отправит подтверждение своевременно, соединение завершится с ошибкой WSAETIMEDOUT.

• SO_UPDATE_ACCEPT_CONTEXT — обновлять контекст сокета из accept() контекстом прослушивающего сокета из вызова listen(). Иными словами, если опция установлена, новые сокеты, которые были приняты с прослушивающего сокета, унаследуют его опции. Эта опция должна быть включена, чтобы сокеты наследовали параметры QoS, а также работали функции getsockname(), getpeername() и getsockopt()/setsockopt() на принятом функцией AcceptEx()141 соединении.

• SO_UPDATE_CONNECT_CONTEXT — опция используется с функциями ConnectEx(), WSAConnectByList() и WSAConnectByName(). Эта опция обновляет свойства сокета после установления соединения. Ее следует установить, если в подключенном сокете будут использоваться функции getpeername(), getsockname(), getsockopt(), setsockopt() или shutdown(), для обновления контекста нового сокета аналогично предыдущей.

Опции управления провайдером и завершением соединения:

• PVD_CONFIG — позволяет задать параметры для сервис-провайдера. Функциональность опции полностью определяется используемым сервис-провайдером.

• SO_DONTLINGER — закрытие сокета не блокируется. В ином случае будет ожидаться подтверждение отправки неотправленных данных перед закрытием. Это опция просто включает или выключает отправку данных при разрыве соединения, не изменяя тайм-аут, заданный SO_LINGER. Не поддерживается дейтаграммными сокетами.

Опция SO_DONTLINGER была также реализована в ядре Linux 2.6.25 для совместимости, но затем удалена за ненадобностью.

Опции, влияющие на безопасность:

• SO_EXCLUSIVEADDRUSE — запретить другому приложению использовать для прослушивания тот же порт, что и сервер, даже на разных адресах. Опция может повысить безопасность серверных приложений142, защищая сервер от захвата порта злонамеренным приложением. Конфликтует с SO_REUSEADDR. Может быть установлена, только если приложение запущено пользователем из группы администраторов.

• SO_RANDOMIZE_PORT — если опция установлена, для сокета будет выбран случайный эфемерный порт. Если для порта установлена опция SO_REUSE_UNICASTPORT, установка SO_RANDOMIZE_PORT завершится с ошибкой. Эта опция по умолчанию устанавливается для сокетов, которые еще не привязаны к адресу.

Опции только для сокетов, передающих сообщения:

• SO_MAX_MSG_SIZE — вернуть максимальный размер исходящего сообщения для сокетов, ориентированных на сообщения.

• SO_MAXDG — вернуть максимальный размер в байтах для исходящих дейтаграмм, поддерживаемых протоколом.

• SO_MAXPATHDG — вернуть максимальный размер в байтах для исходящих дейтаграмм, поддерживаемых протоколом на всем пути к указанному адресу.

Опции групп сокетов:

• SO_GROUP_ID — идентификатор группы сокетов.

• SO_GROUP_PRIORITY — приоритет сокета в группе относительно других сокетов. Значения — неотрицательные целые. Нулевое значение соответствует высшему приоритету.

Опции, которые не поддерживаются стеком TCP/IP:

• SO_CONNDATA, SO_CONNOPT — дополнительные данные, отправляемые с сетевыми запросами для установления соединения.

• SO_CONNDATALEN, SO_CONNOPTLEN — длина дополнительных данных, отправляемых при соединении.

• SO_DISCDATA, SO_DISCOPT — дополнительные данные, отправляемые с сетевыми запросами на разрыв соединения.

• SO_DISCDATALEN, SO_DISCOPTLEN — длина дополнительных данных для отключения.

• SO-USELOOPBACK — использовать локальную петлю для отправки данных. Этот параметр следует использовать в том случае, если все отправленные данные будут получены только локально без обработки их сетевым оборудованием.

Параметры, оканчивающиеся на OPT, используются устаревшими протоколами, такими как DECNet, OSI TP4 и подобными. Означают эти параметры то же, что и параметры, оканчивающиеся на DATA. Но значения констант разные.

Внимание! Далеко не все опции поддерживаются всеми версиями Windows. Например, SO_BSP_STATE и опции групп поддерживаются, начиная с ОС Windows Vista, опция SO_PROTECT поддерживалась только в ОС Windows 2003 Server, а опции SO_RCVLOWAT и SO_SNDLOWAT не поддерживаются вообще. За подробностями обращайтесь к MSDN.

Уровень IP

Параметр level должен быть равен IPPROTO_IP или SOL_IP.

Список опций IP приведен в разделе MSDN IPPROTO_IP Socket Options, доступном по ссылке https://docs.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options.

Опции, используемые для управления фильтром интерфейсов дейтаграммных сокетов:

• IP_IFLIST — включить или выключить фильтрацию по списку интерфейсов либо получить значение флага. Когда эта опция включена, прием дейтаграмм ограничивается интерфейсами в списке. Дейтаграммы, полученные через любые другие интерфейсы, будут проигнорированы.

• IP_GET_IFLIST — получить массив индексов разрешенных интерфейсов.

• IP_ADD_IFLIST — добавить индекс интерфейса в список разрешенных.

• IP_DEL_IFLIST — удалить индекс интерфейса из списка разрешенных.

Записи в списках не удаляются автоматически и могут быть удалены только приложением. Если, например, интерфейс был удален из системы, список может устареть.

Сконвертировать индекс интерфейса в имя позволит функция if_indextoname().

Управление фрагментацией:

• IP_DONTFRAGMENT — не фрагментировать данные протоколов, ориентированных на сообщения, таких как UDP и ICMP, независимо от значения локального MTU. Аналог IP_NODEFRAG, которого в ОС Windows нет.

Опции интерфейсов:

• IP_ORIGINAL_ARRIVAL_IF — указывает, должна ли функция WSARecvMsg() возвращать в управляющих данных индекс интерфейса, через который был получен пакет. Параметр работает только для дейтаграммных и raw-сокетов. Используется с технологиями перехода на IPv6, такими как 6to4, ISATAP и Teredo, чтобы получить оригинальный интерфейс, после того как пакет через локальный туннель, имеющий свои интерфейсы, перешел с IPv4 в IPv6 или обратно.

• IP_RECVIF — указывает, должен ли стек IP-адресов заполнять в буфере управления индекс интерфейса, через который была получена дейтаграмма. Если это значение равно 1, функция WSARecvMsg() вернет управляющие данные, содержащие этот индекс.

• IP_UNICAST_IF — возвращает или задает индекс интерфейса для отправки трафика IPv4. Узнать индекс интерфейса можно с помощью функции GetAdaptersAddresses().

Некоторые информационные опции:

• IP_RECVDSTADDR — если параметр установлен, функция WSARecvMsg() вернет адрес получателя из дейтаграммы.

• IP_HOPLIMIT — получить значение максимального количества ретрансляций. По сути, для IP это TTL.

• IP_TCLASS — класс трафика пакета.

• IP_RECVTCLASS — получать класс трафика пакета со вспомогательными данными при вызове WSARecvMsg().

• IP_PKTINFO_EX — получить расширенную информацию о пакете.

Структура для опции IP_PKTINFO_EX включает IN_PKTINFO и scope_id:

#include <ws2ipdef.h>

typedef struct in_pktinfo

{

// Адрес источника либо назначения.

IN_ADDR ipi_addr;

// Индекс интерфейса.

ULONG ipi_ifindex;

} IN_PKTINFO, *PIN_PKTINFO;

typedef struct in_pktinfo_ex

{

// IN6_PKTINFO для IPv6.

IN_PKTINFO pkt_info;

// Идентификатор области.

SCOPE_ID scope_id;

} IN_PKTINFO_EX, *PIN_PKTINFO_EX;

Для адресов IPv6 структура называется IPV6_PKTINFO_EX и содержит IPv6-адреса.

Широковещательный обмен данными:

• IP_RECEIVE_BROADCAST — разрешить или блокировать прием широковещательного трафика.

Опции WFP:

• IP_WFP_REDIRECT_CONTEXT, IP_WFP_REDIRECT_RECORDS — вспомогательные данные сокета, указывающие контекст и записи перенаправления для сокета UDP, используемого службой WFP пользовательского режима. Тип — cmsg_type, данные возвращаются в виде структуры WSACMSGHDR.

Прочее:

• IP_ECN — получать уведомления о перегрузке.

• IP_RTHDR — получить или установить заголовок маршрутизации IPv6.

• IP_RECVRTHDR — получать заголовок маршрутизации во вспомогательных данных.

• IP_NRT_INTERFACE — отметить интерфейс как работающий в режиме мягкого реального времени.

Операционная система мягкого реального времени — Near Real-Time system — отличается тем, что для выполнения некоторого вызова существует достаточно малый временной интервал.

Точное время завершения, как в жестких ОС РВ, неизвестно — это вызвано джиттером, который вносит система. Такой системой является Windows 10 IoT Enterprise, которая была выпущена для работы с промышленными IoT-устройствами.

Опция IP_NRT_INTERFACE — часть изменений, требуемых для поддержки работы приложений реального времени.

Уровень IPv6

Параметр level должен быть равен IPPROTO_IPV6.

Список опций IP приведен в разделе MSDN IPPROTO_IPV6 Socket Options по ссылке https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ipv6-socket-options.

Управление фильтром выполняется через опции IPV6_ADD_IFLIST, IPV6-DEL_IFLIST, IPV6_GET_IFLIST, IPV6_IFLIST, аналогичные таковым для IP.

Для работы с группами многоадресной рассылки, кроме опций, которые были описаны в главе 8, существуют опции IPV6_JOIN_GROUP и IPV6_LEAVE_GROUP, полностью аналогичные IPV6_ADD_MEMBERSHIP и IPV6_DROP_MEMBERSHIP.

Опция IPV6_PKTINFO позволяет включать и выключать доставку сообщения, которое содержит информацию о пакете. Это аналог опции IPV6_RECVPKTINFO в Linux.

Опция управления фрагментацией пакетов называется IPV6_DONTFRAG.

Опции интерфейсов IPV6_RECVIF и IPV6_UNICAST_IF аналогичны таковым для IPv4.

Информационные опции, аналогичные опциям IPv4: IPV6_RECVDSTADDR, IPV6_RECVRTHDR, IPV6_PKTINFO_EX.

Опции, влияющие на безопасность:

• IPV6_PROTECTION_LEVEL — позволяет накладывать ограничения на доступ к сокетам IPv6 для защиты от внешних атак путем сужения диапазона адресов, с которыми возможен обмен:

• PROTECTION_LEVEL_UNRESTRICTED — используется приложениями, предназначенными для работы через интернет, включая приложения, использующие возможности обхода NAT по протоколу IPv6 через Teredo.

• PROTECTION_LEVEL_EDGERESTRICTED — используется приложениями, предназначенными для работы через интернет. Но этот параметр не разрешает обход NAT с помощью реализации Windows Teredo.

• PROTECTION_LEVEL_RESTRICTED — используется приложениями в локальной сети. Эти приложения обычно не тестируются и не защищаются против атак из интернета. Параметр ограничивает получаемый трафик локальным.

Приложения, работающие через интернет, могут обходить брандмауэр, поэтому они должны быть защищены разработчиком от атак из интернета, направленных на открытый порт.

Teredo — это технология, обеспечивающая передачу IPv6-трафика по сетям IPv4. В отличие от таких протоколов, как 6to4, может работать даже на устройствах, которые находятся за NAT, то есть позволяет обходить NAT. Она использует протокол туннелирования, инкапсулирующий пакеты IPv6 в UDP-дейтаграммы поверх IPv4.

Стандартизирована в RFC 4380 «Teredo: Tunneling IPv6 over UDP through Network Address Translations».

Управление классом и MTU:

• IPV6_MTU — позволяет получить MTU. Однако не позволяет его установить, в отличие от Linux.

• IPV6_TCLASS — класс трафика пакета.

• IPV6_RECVTCLASS — если опция истинна, функция WSARecvMsg() вернет опциональные вспомогательные данные, содержащие значение класса трафика из заголовка IPv6. Параметр работает только на дейтаграммных сокетах.

• IPV6_USER_MTU — позволяет получить или установить верхнюю границу MTU в байтах для сокета. Если значение меньше системного MTU, исходящие пакеты, размер которых превышает это значение, либо будут фрагментированы, либо не будут отправляться — в зависимости от значения IPV6_DONTFRAG. Значение по умолчанию — IP_UNSPECIFIED_USER_MTU.

Raw-сокеты IPv6:

• IPV6_HDRINCL — то же, что и IP_HDRINCL, но для IPv6.

• IPV6_CHECKSUM — смещение контрольной суммы для отправки через raw-сокет.

Опции WFP — IPV6_WFP_REDIRECT_CONTEXT и IPV6_WFP_REDIRECT_RECORDS, назначение которых аналогично значению опций для IPv4.

Из прочих опций доступна IPV6_NRT_INTERFACE, аналогичная по назначению таковой для IPv4.

Уровень UDP

Параметр level должен быть равен IPPROTO_UDP.

Список опций IP приведен в разделе MSDN «IPPROTO_UDP Socket Options» по ссылке:

https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-udp-socket-options.

Управление контрольной суммой в UDP:

• UDP_CHECKSUM_COVERAGE — если задан, UDP-дейтаграммы отправляются с контрольной суммой.

• UDP_NOCHECKSUM — если задан, UDP-дейтаграммы отправляются с нулевой контрольной суммой. Если у провайдера нет механизма отключения вычисления контрольной суммы UDP, он может просто сохранить этот параметр, не предпринимая никаких действий. Параметр не поддерживается для IPv6.

Управление размером дейтаграмм:

• UDP_RECV_MAX_COALESCED_SIZE — аналог UDP_CORK в Linux. Если его значение отлично от 0, несколько полученных дейтаграмм могут быть объединены в одно сообщение, прежде чем будут переданы приложению. Значение параметра — максимальный размер сообщения в байтах после объединения.

• UDP_SEND_MSG_SIZE — если имеет значение, отличное от 0, сообщения, отправляемые приложением, разбиваются на несколько отдельных сообщений сетевым стеком. Значение параметра представляет размер каждого фрагмента сообщения в байтах.

Функции WSAGetUdpRecvMaxCoalescedSize() и WSASetUdpRecvMaxCoalescedSize() позволяют задать или получить максимальный размер объединенной UDP-дейтаграммы:

int WSAGetUdpRecvMaxCoalescedSize(SOCKET Socket, DWORD *MaxCoalescedMsgSize);

int WSASetUdpRecvMaxCoalescedSize(SOCKET Socket, DWORD MaxCoalescedMsgSize);

WSAGetUdpSendMessageSize() и WSASetUdpSendMessageSize() — функции для получения и установки размера сообщения, используемого на сокете для сегментации UDP:

int WSAGetUdpSendMessageSize(SOCKET Socket, DWORD *MsgSize);

int WSASetUdpSendMessageSize(SOCKET Socket, DWORD MsgSize);

Эти функции являются безопасной оберткой для получения и установки опции сокета UDP_SEND_MSG_SIZE.

Все функции вернут 0 в случае успеха или SOCKET_ERROR в случае ошибки.

Уровень TCP

Параметр level должен быть равен IPPROTO_TCP.

Список опций IP приведен в разделе MSDN IPPROTO_TCP Socket Options по ссылке:

https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-tcp-socket-options.

Настроек TCP в ОС Windows меньше, чем в Linux.

Обработка внеполосных данных:

• TCP_BSDURGENT — если имеет значение «истина», стек будет использовать BSD-стиль для обработки внеполосных данных. Параметр противоположен TCP_EXPEDITED_1122.

• TCP_EXPEDITED_1122 — если имеет значение «истина», используется обработка внеполосных данных, как указано в RFC 1122 Requirements for Internet Hosts.

Подключение и управление соединением:

• TCP_FAIL_CONNECT_ON_ICMP_ERROR — если имеет значение «истина», вызов connect() возвращается при получении ошибки ICMP со значением WSAEHOSTUNREACH, а сокет будет закрыт. В противном случае сокет будет вести себя как обычно. Исходный адрес ошибки будет доступен через параметр сокета TCP_ICMP_ERROR_INFO.

• TCP_ICMP_ERROR_INFO — прочитать сведения об ошибке ICMP, полученной сокетом TCP в результате неудачного подключения, если была установлена опция TCP_FAIL_CONNECT_ON_ICMP_ERROR. Ошибка будет возвращена как структура ICMP_ERROR_INFO.

• TCP_NOSYNRETRIES — если опция установлена, не отправлять SYN при активной попытке TCP-соединения.

• TCP_CONGESTION_ALGORITHM — выбрать алгоритм избежания перегрузок. Опция принимает значения от 0 до 7. Опция не документирована, но судя по командлетам PowerShell, некоторые ее значения таковы:

• 0 — Default. Алгоритм по умолчанию. Вероятно, это NewReno, но зависит от версии ОС.

• 1 — NewReno. Описан в RFC 6582 «The NewReno Modification to TCP's Fast Recovery Algorithm».

• 2 — CTCP. Compound TCP — алгоритм от Microsoft, появившийся в стеке Vista. Старается уменьшить время отклика и увеличить пропускную способность. Описан в IETF draft «Compound TCP: A New TCP Congestion Control for High-Speed and Long Distance Networks».

• 3 — DCTCP. Data Center TCP. Уменьшает задержки и потери пакетов. Описан в RFC 8257 «Data Center TCP (DCTCP): TCP Congestion Control for Data Centers».

• 4 — LEDBAT. Увеличивает задержку, используя доступную полосу пропускания. Описан в RFC 6817 «Low Extra Delay Background Transport (LEDBAT)».

• 5 — CUBIC. Стандартный алгоритм для быстрых и протяженных сетей. Описан в «RFC 9438 CUBIC for Fast and Long-Distance Networks».

Структура ICMP_ERROR_INFO:

#include <ws2ipdef.h>

typedef struct icmp_error_info

{

// IP-адрес источника ошибки ICMP.

SOCKADDR_INET srcaddress;

// Протокол ошибки ICMP: IPPROTO_ICMP или IPPROTO_ICMPV6.

IPPROTO protocol;

// Тип ошибки ICMP.

UINT8 type;

// Код ошибки ICMP.

UINT8 code;

} ICMP_ERROR_INFO, *PICMP_ERROR_INFO;

Функции WSAGetFailConnectOnIcmpError() и WSASetFailConnectOnIcmpError() также позволяют как получать, так и устанавливать значение опции TCP_FAIL_CONNECT_ON_ICMP_ERROR:

int WSAGetFailConnectOnIcmpError(SOCKET Socket, DWORD *Enabled);

int WSASetFailConnectOnIcmpError(SOCKET Socket, DWORD Enabled);

Если параметр Enabled установлен в false, сокет будет продолжать работать, даже если он получит сообщение об ошибке ICMP.

Обертка над опцией TCP_ICMP_ERROR_INFO — функция WSAGetIcmpErrorInfo().

Она принимает дескриптор сокета и указатель на структуру ICMP_ERROR_INFO, в которую будет записана информация об ошибке ICMP:

int WSAGetIcmpErrorInfo(SOCKET Socket, ICMP_ERROR_INFO *Info);

Тайм-ауты и временные метки:

• TCP_MAXRT — количество секунд ожидания подключения. Значение –1 отключает тайм-аут. В этом случае время ожидания экспоненциально увеличивается для каждого повторного соединения, пока не достигнет максимального значения. Максимальное значение по умолчанию — 60 секунд.

• TCP_MAXRTMS — количество миллисекунд ожидания подключения. В остальном аналогична опции TCP_MAXRT и похожа на опцию TCP_USER_TIMEOUT в Linux. Поддерживается, начиная с ОС Windows 10 1607, и в приложениях, ориентированных на все новые версии ОС, следует использовать эту опцию, а не TCP_MAXRT.

• TCP_TIMESTAMPS — включить или отключить метки времени по стандарту RFC 1323 «TCP Extensions for High Performance».

Вспомогательные данные

WinSock позволяет работать со вспомогательными данными, которые используются для работы с некоторыми параметрами сокетов и протоколов. Делать это можно, применяя функции, которые являются расширениями Microsoft. Для отправки данных используется функция WSASendMsg():

#include <Mswsock.h>

int WSAAPI WSASendMsg(

SOCKET handle,

LPWSAMSG lpMsg,

DWORD dwFlags,

LPDWORD lpNumberOfBytesSent,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Параметры функции WSASendMsg():

• handle — дескриптор сокета.

• lpMsg — указатель на отправляемые данные. Соответствует структуре msghdr в POSIX.

• dwFlags — флаги, изменяющие поведение функции. Поддерживаются уже рассмотренные в главе 8 флаги MSG_DONTROUTE и MSG_PARTIAL. Напомним, что MSG_DONTROUTE игнорируется провайдером от Microsoft.

• lpNumberOfBytesSent — указатель на количество байтов, отправленных вызовом, если операция ввода-вывода завершается немедленно.

• lpOverlapped — необязательный указатель на структуру WSAOVERLAPPED.

• lpCompletionRoutine — указатель на функцию завершения, вызываемую после завершения передачи данных.

Возвращает 0 в случае успеха и SOCKET_ERROR при неудаче. Для получения данных используется функция WSARecvMsg():

#include <Mswsock.h>

int WSARecvMsg(

SOCKET s,

LPWSAMSG lpMsg,

LPDWORD lpdwNumberOfBytesRecvd,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Параметры функции WSARecvMsg():

• s — дескриптор сокета.

• lpMsg — буфер для принимаемых сообщений.

• lpdwNumberOfBytesRecvd — указатель на переменную, в которую будет записано количество принятых байтов, если вызов блокирующий.

• lpOverlapped — необязательный указатель на структуру WSAOVERLAPPED.

• lpCompletionRoutine — указатель на функцию завершения, вызываемую после завершения операции передачи.

Возвращаемые значения такие же, как и для WSASendMsg().

Структура, которая используется в данных функциях, не является POSIX-совместимой. Она немного отличается по именам атрибутов и составу:

#include <winsock2.h>

typedef struct _WSAMSG

{

// Указатель на структуру SOCKET_ADDRESS,

// в которой хранится информация об адресе удаленного абонента.

// Используется с сокетами без соединения.

LPSOCKADDR name;

// Размер name в байтах.

int namelen;

// Массив структур WSABUF, используемый для получения данных сообщения.

LPWSABUF lpBuffers;

// Количество буферов в массиве lpBuffers. Тип может быть DWORD.

ULONG dwBufferCount;

// Опциональные управляющие данные.

WSABUF Control;

// Управляющие флаги. Тип может быть DWORD.

ULONG dwFlags;

} WSAMSG, *PWSAMSG, *LPWSAMSG;

Заголовок вспомогательных данных такой же, как в Linux:

#include <winsock2.h>

struct wsacmsghdr

{

// Размер данных вместе с заголовком.

UINT cmsg_len;

// Протокол, например IPPROTO_IP или IPPROTO_IPV6.

int cmsg_level;

// Тип вспомогательных данных.

int cmsg_type;

// После заголовка идет массив данных cmsg_data[].

} WSACMSGHDR;

За ним идут данные, для работы с которыми используется набор макросов:

#include <winsock2.h>

#define LPCMSGHDR *WSA_CMSG_FIRSTHDR(LPWSAMSG msg);

#define LPCMSGHDR *WSA_CMSG_NXTHDR(LPWSAMSG msg, LPWSACMSGHDR cmsg);

#define UCHAR *WSA_CMSG_DATA(LPWSACMSGHDR pcmsg);

#define UINT WSA_CMSG_SPACE(UINT length);

#define UINT WSA_CMSG_LEN(UINT length);

Они аналогичны макросам в Linux, но называются иначе:

• WSA_CMSG_FIRSTHDR() — получить указатель на первый заголовок cmsghdr или nullptr.

• WSA_CMSG_NXTHDR() — получить указатель на следующий заголовок cmsghdr.

• WSA_CMSG_SPACE() — получить размер буфера для элемента данных.

• WSA_CMSG_LEN() — получить выровненную длину элемента вспомогательных данных для сохранения в поле cmsg_len структуры cmsghdr.

• WSA_CMSG_DATA() — получить указатель на начало данных, идущих после заголовка cmsghdr.

Сообщения будут приходить, когда установлены опции, описанные ранее, либо когда сделан запрос через WSARecvMsg().

В заголовке управляющих данных, в поле Control для получения или установки параметров необходимо установить опции.

Для протокола IP это:

• IP_ORIGINAL_ARRIVAL_IF — получить интерфейс, откуда пришли данные.

• IP_PKTINFO — задать или получить вместе с данными информацию о пакете.

• IP_ECN — задать или получить явное уведомление о перегрузке.

Структуры, которые содержат информацию о пакете для IP_PKTINFO и IPV6_PKTINFO, предоставляют только адрес и индекс интерфейса:

// Для IPv4.

typedef struct in_pktinfo

{

IN_ADDR ipi_addr;

ULONG ipi_ifindex;

} IN_PKTINFO, *PIN_PKTINFO;

// Для IPv6.

typedef struct in6_pktinfo

{

IN6_ADDR ipi6_addr;

ULONG ipi6_ifindex;

} IN6_PKTINFO, *PIN6_PKTINFO;

Для IPv6:

• IPV6_DSTOPTS — получить или установить параметры заголовка для отправляемых пакетов, то есть поля заголовка опций IPv6.

• IPV6_HOPLIMIT — получить или установить максимальное количество промежуточных узлов, через которые может быть ретранслирован пакет, иными словами — TTL.

• IPV6_HOPOPTS — отправить или получить опции, которые передаются между промежуточными узлами.

• IPV6_NEXTHOP — адрес следующего промежуточного узла.

• IPV6_PKTINFO — получить информацию о пакете.

• IPV6_RTHDR — задать или получить заголовок маршрутизации IPv6.

• IPV6_ECN — получать явное уведомление о перегрузке.

Опции IP_ECN и IPV6_ECN специфичны для Windows. ECN, или явное уведомление о перегрузке, — это механизм, позволяющий ECN-абонентам узнать о возникновении затора в сети без отбрасывания пакетов, через установку битов в заголовке IP.

Подобное, например, используется в протоколе QUIC.

Помимо опций ECN, во вспомогательных данных есть две функции, которые требуются для работы с ним.

Функция WSAGetRecvIPEcn() дает возможность узнать, включено ли получение сообщений о ECN, а функция WSASetRecvIPEcn() позволяет включить или выключить заполнение стеком кодовых точек ECN:

#include <ws2tcpip.h>

int WSAGetRecvIPEcn(

SOCKET Socket,

DWORD  *Enabled

);

int WSASetRecvIPEcn(

SOCKET Socket,

DWORD  Enabled

);

Подробнее о ECN можно прочитать в разделе MSDN Winsock Explicit Congestion Notification (ECN) и в RFC 3168 «The Addition of Explicit Congestion Notification (ECN) to IP».

Функции сокетного ioctl

Частичный аналог функции ioctl() в ОС Windows называется ioctlsocket(). Прототип и возможности этих функций различаются достаточно сильно:

#include <winsock2.h>

int WSAAPI ioctlsocket(SOCKET s, long cmd, u_long *argp);

Параметры функции ioctlsocket():

• s — дескриптор сокета.

• cmd — команда, выполняемая над сокетом.

• argp — указатель на параметр команды.

В случае успеха функция возвращает 0, а в случае ошибки — SOCKET_ERROR.

Как пример использования переведем сокет в неблокирующий режим:

int i_mode = 1;

if (ioctlsocket(m_socket, FIONBIO, &i_mode) != NO_ERROR)

std::cerr

<< "ioctlsocket failed with error:" << WSAGetLastError()

<< std::endl;

Расширенным вариантом ioctlsocket() является функция WSAIoctl(). Она также используется для задания или извлечения параметров, связанных с сокетом, транспортным протоколом или подсистемой связи.

Ее прототип:

#include <winsock2.h>

int WSAAPI WSAIoctl(

SOCKET s,

DWORD dwIoControlCode,

LPVOID lpvInBuffer,

DWORD cbInBuffer,

LPVOID lpvOutBuffer,

DWORD cbOutBuffer,

LPDWORD lpcbBytesReturned,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine

);

Параметры функции WSAIoctl():

• s — дескриптор, определяющий сокет.

• dwIoControlCode — выполняемая команда.

• lpvInBuffer — указатель на входной буфер.

• cbInBuffer — размер входного буфера в байтах.

• lpvOutBuffer — указатель на выходной буфер.

• cbOutBuffer — размер выходного буфера в байтах.

• lpcbBytesReturned — указатель на фактическое количество байтов, которое вернула функция.

• lpOverlapped — необязательный указатель на структуру WSAOVERLAPPED.

• lpCompletionRoutine — необязательный указатель на подпрограмму завершения для сокетов, использующих перекрывающийся ввод-вывод.

Возвращаемые значения такие же, как у функции ioctlsocket().

Собственно, «расширенной» относительно ioctlsocket() данная функция является лишь потому, что может работать в overlapped-режиме.

Некоторые вызовы ioctl могут блокироваться на неопределенное время, и для того, чтобы не блокировать выполнение кода такими вызовами, можно использовать функцию WSAIoctl().

Блокировка ioctl зависит от провайдера, но в штатном провайдере от Microsoft реализованы следующие блокирующие вызовы:

• SIO_ADDRESS_LIST_CHANGE;

• SIO_FINDROUTE;

• SIO_FLUSH;

• SIO_GET_QOS;

• SIO_GET_GROUP_QOS.

• SIO_ROUTING_INTERFACE_CHANGE.

• SIO_SET_QOS.

• SIO_SET_GROUP_QOS.

Эти вызовы мы опишем далее.

Дополнительно существует функция WSANSPIoctl(), позволяющая выполнять вызовы управления вводом-выводом в зарегистрированном пространстве имен. Функция используется для выполнения единственного вызова: SIO_NSP_NOTIFY_CHANGE.

Состав сокетного ioctl

Так же как и в Linux, в ОС Windows коды ioctl структурированы. Их формат следующий:

I

O

V

T

Семейство провайдеров или адресов

Код

3

3

2

22

22222221111

11111

1

0

9

87

65432109876

543210...

Биты читаются сверху вниз и слева направо, то есть 31, 30, 29, ...

Параметры имеют следующие значения:

• I — ioctl использует входной буфер. Пример: IOC_IN.

• O — ioctl использует выходной буфер. Пример: IOC_OUT.

• V — ioctl не имеет параметров. Пример: IOC_VOID.

• T — 2-битное число, определяющее тип ioctl:

• 0 — Unix ioctl, используемый в BSD. Например, FIONREAD и FIONBIO.

• 1 — универсальный ioctl Windows Sockets 2.

• 2 — ioctl применяется только к заданному семейству адресов.

• 3 — ioctl применяется только к заданному провайдеру, как и IOC_VENDOR.

• Семейство провайдеров транспорта или семейства адресов — 11-битное число, определяющее провайдер, которому принадлежит код, если T == 3 или содержит семейство адресов, к которому применяется код, если T == 2. Если это код ioctl Unix, то этот параметр имеет то же значение, что и код в Unix.

• Код — 16-разрядное количество, содержащее конкретный код ioctl для операции.

Коды ioctl описаны в MSDN по ссылке https://docs.microsoft.com/ru-ru/windows/win32/winsock/winsock-ioctls. В каждом из кодов указана структура, но для краткости мы приведем только сами коды.

Рассмотрим поддерживаемые WinSock коды ioctl.

Unix ioctl

Отдельно стоит выделить Unix-совместимые ioctl. В WinSock их совсем немного:

• FIONBIO — включить или отключить неблокирующий режим сокета.

• FIONREAD — получить количество непрочитанных данных в очереди приема.

• SIOCATMARK — возвращает значение, не равное 0, если входящий поток данных находится на отметке срочности.

Подробнее эти вызовы описаны в главе 10. Далее мы рассмотрим ioctl WinSock2.

Управление дескриптором

Коды ioctl для перевода дескрипторов сокетов в дескрипторы базового провайдера.

Переводить одни дескрипторы в другие необходимо при добавлении новых функций расширения WinSock, чтобы предотвратить нарушения работы LSP, которые не работают с IFS:

• SIO_BASE_HANDLE — вернуть базовый дескриптор для заданного сокета. Код ioctl, используемый для преобразования дескриптора сокета в дескриптор базового провайдера. Он не используется какой-либо функцией расширения WinSock и не должен перехватываться LSP Winsock.

• SIO_BSP_HANDLE — вернуть базовый дескриптор сокета для использования в функции WSASendMsg().

• SIO_BSP_HANDLE_SELECT — вернуть базовый дескриптор сокета для использования в функции select().

• SIO_BSP_HANDLE_POLL — вернуть базовый дескриптор сокета для использования в функции WSAPoll().

• SIO_TRANSLATE_HANDLE — получает соответствующий дескриптор для сокетов, допустимый в контексте интерфейса-компаньона, например TH_NETDEV и TH_TAPI.

Управление уведомлениями

События на сокетах могут генерировать уведомления, и для их включения существует несколько вызовов:

• SIO_SOCKET_CLOSE_NOTIFY — установить уведомление при закрытии данного сокета.

• SIO_QUERY_TARGET_PNP_HANDLE — запросить у базового провайдера дескриптор, который можно использовать для получения уведомлений о событиях Plug and Play.

• SIO_NSP_NOTIFY_CHANGE — проверить, являются ли по-прежнему допустимыми результаты, возвращаемые по дескриптору запроса hLookup предыдущими вызовами WSALookupServiceBegin() или WSALookupServiceNext().

Управление сетевыми интерфейсами

Вызовы для получения интерфейсов и привязки к ним сокетов:

• SIO_ASSOCIATE_HANDLE — связать сокет с дескриптором интерфейса. Поскольку флаг MSG_DONTROUTE и аналогичная опция в ОС Windows игнорируются провайдером от Microsoft, этот ioctl полезен для того, чтобы указать, какой интерфейс будет использован для отправки данных сокетом.

• SIO_GET_INTERFACE_LIST — получить список настроенных IP-интерфейсов и их параметров в виде массива структур INTERFACE_INFO.

• SIO_GET_INTERFACE_LIST_EX — возвращает список настроенных IP-интерфейсов и их параметров в виде массива INTERFACE_INFO_EX структур.

• SIO_INDEX_BIND — привязать сокет к индексу интерфейса, указанному в качестве входного параметра, а не к адресу.

• SIO_ROUTING_INTERFACE_CHANGE — получать уведомления о любых изменениях в локальном интерфейсе маршрутизации, используемом для обмена данными с удаленным адресом. Входной буфер — структура SOCKADDR, которая содержит удаленный адрес. Если интерфейс заданного маршрута изменится, приложение будет уведомлено. При использовании адреса INADDR_ANY уведомления будут приходить о любых изменениях маршрутизации. Ожидать событий изменения маршрутизации можно через WSAEventSelect() или WSAAsyncSelect() с установленным в маске событий флагом FD_ROUTING_INTERFACE_CHANGE. Либо использовать перекрывающийся ввод-вывод: дескриптор события, который сигнализирует об изменении маршрутизации, должен быть задан в структуре WSAOVERLAPPED.

• SIO_ROUTING_INTERFACE_QUERY — определить, какой интерфейс следует использовать для отправки данных абоненту. Провайдеры транспорта могут отправлять уведомление об изменении маршрутизации всегда, не учитывая адрес. Поэтому каждый раз необходимо вызывать SIO_ROUTING_INTERFACE_QUERY для получения интерфейса. Вызову передается адрес удаленного абонента — структуры SOCKADDR. В выходной буфер будет записан массив из одной или нескольких структур SOCKADDR, с адресами локальных интерфейсов, которые могут быть использованы.

Для более плотной работы с интерфейсами, вероятно, лучше использовать не эти ioctl, а функции IP Helper API.

Структура INTERFACE_INFO выглядит следующим образом:

#include <ws2ipdef.h>

// Тип адреса в структуре ниже.

typedef union sockaddr_gen

{

struct sockaddr Address;

struct sockaddr_in AddressIn;

struct sockaddr_in6_old AddressIn6;

} sockaddr_gen;

typedef struct _INTERFACE_INFO

{

// Состояние интерфейса.

ULONG iiFlags;

// Адрес интерфейса.

sockaddr_gen iiAddress;

// Широковещательный адрес интерфейса.

// Для соединения "точка-точка" — адрес подключенного абонента.

sockaddr_gen iiBroadcastAddress;

// Маска сети, которую использует данный интерфейс.

sockaddr_gen iiNetmask;

} INTERFACE_INFO, *LPINTERFACE_INFO;

Флаги в поле iiFlags:

• IFF_UP — интерфейс активен и работает.

• IFF_BROADCAST — поддерживается широковещательная передача.

• IFF_LOOPBACK — интерфейс локальной петли.

• IFF_POINTTOPOINT — интерфейс типа «точка-точка».

• IFF_MULTICAST — поддерживается многоадресная рассылка.

Единственное отличие структуры INTERFACE_INFO_EX в том, что тип адресов в ней — SOCKET_ADDRESS, а не sockaddr_gen.

Остальные структуры мы уже рассматривали ранее.

Операции ввода-вывода

Вызовы, перечисленные ниже, позволяют выполнить настройку ввода-вывода:

• SIO_ENABLE_CIRCULAR_QUEUEING — включить циклическую очередь. Настраивает базовый провайдер транспорта на работу при переполнении входящей очереди дейтаграммных сообщений. Если опция включена, новое сообщение никогда не будет удалено из-за переполнения. Вместо этого удаляется самое старое сообщение в очереди. Опция применима только для ненадежных протоколов, ориентированных на сообщения.

• SIO_FIND_ROUTE — найти маршрут до удаленного адреса. Входной параметр — указатель на sockaddr. Если маршрут к адресу уже есть в кэше, он будет удален и найден повторно. Используется для проверки доступности адреса. Провайдер TCP/IP от Microsoft может не реализовывать этот вызов в некоторых версиях ОС.

• SIO_FLUSH — удалить содержимое очереди отправки сокета.

Управление буферами

Чтобы узнать размер буфера, который будет оптимальным для выделения пользователем, существуют следующие вызовы:

• SIO_IDEAL_SEND_BACKLOG_QUERY — позволяет узнать идеальный размер буфера отправки, который зависит от многих факторов, таких как пропускная способность сети, задержка, потери пакетов, алгоритмы управления перегрузкой и т.д.

• SIO_IDEAL_SEND_BACKLOG_CHANGE — уведомляет приложение при изменении идеального размера буфера отправки.

Функция idealsendbacklogquery() возвращает размер идеального буфера отправки в байтах или SOCKET_ERROR, если произошла ошибка:

int idealsendbacklogquery(SOCKET s, ULONG *pISB);

Параметры функции idealsendbacklogquery():

• s — дескриптор сокета;

• pISB — указатель на размер буфера.

Если приложение использует слишком маленький буфер для отправки данных, оно тратит больше времени на ожидание подтверждений. Если же буфер слишком большой, он занимает излишек памяти. Кроме того, это вызывает задержки при отправке данных, так как перед отправкой буфер необходимо заполнить.

Включать уведомления об изменении идеального размера буфера позволяет функция idealsendbacklognotify():

int idealsendbacklognotify(

SOCKET s,

LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

Параметры функции idealsendbacklognotify():

• s — дескриптор сокета.

• lpOverlapped — указатель на структуру WSAOVERLAPPED, если сокет перекрывающийся, и nullptr, если нет.

• lpCompletionRoutine — указатель на функцию, вызываемую при завершении операции для перекрывающихся сокетов.

Как ioctl, так и функция idealsendbacklognotify(), работающая поверх него, принимают lpCompletionRoutine — указатель на функцию, которая будет вызвана при необходимости изменения размера буфера.

Привязка обработки к процессорному ядру

Если алгоритмы сокета выполняются на определенном ядре, часть данных можно держать в кэше ядра, а не загружать каждый раз из памяти.

В некоторых случаях привязка сокета к ядру может значительно увеличить производительность. Для того чтобы выполнить привязку, существуют следующие опции:

• SIO_QUERY_RSS_SCALABILITY_INFO — включить масштабирование принимающей стороны, или RSS — receive-side scaling. NDIS 5.1 обрабатывал принятые данные на одном процессоре. Данный вызов позволяет включить режим, при котором обработка распределяется между несколькими процессорами.

• SIO_QUERY_RSS_PROCESSOR_INFO — запросить связь между сокетом, ядром процессора RSS и узлом NUMA. Возвращает структуру, содержащую номер группы и относительный номер процессора в группе, а также идентификатор узла NUMA.

• SIO_CPU_AFFINITY — включить распределение обработки данных по ядрам. Каждый сокет привязывается к своему ядру. Все пакеты одного потока данных группируются на основе адресов, портов и протокола. Сгруппированные пакеты будут попадать в один и тот же сокет, который обрабатывается заданным ядром. Вызов необходимо сделать до привязки адресов. Параметр вызова — индекс процессора типа unsigned short, начиная с 0.

SIO_QUERY_RSS_SCALABILITY_INFO принимает структуру для переключения режима:

typedef struct _RSS_SCALABILITY_INFO

{

BOOLEAN RssEnabled;

} RSS_SCALABILITY_INFO, *PRSS_SCALABILITY_INFO;

SIO_QUERY_RSS_PROCESSOR_INFO возвращает структуру SOCKET_PROCESSOR_AFFINITY:

#include <ws2def.h>

// Описание логического процессора.

typedef struct _PROCESSOR_NUMBER

{

// Группа процессора.

WORD Group;

// Номер процессора в группе.

BYTE Number;

// Зарезервировано.

BYTE Reserved;

} PROCESSOR_NUMBER, *PPROCESSOR_NUMBER;

typedef struct _SOCKET_PROCESSOR_AFFINITY

{

// Процессор.

PROCESSOR_NUMBER Processor;

// Идентификатор NUMA-узла.

USHORT NumaNodeId;

// Зарезервировано.

USHORT Reserved;

} SOCKET_PROCESSOR_AFFINITY, *PSOCKET_PROCESSOR_AFFINITY;

Временные метки

Получение временных меток обычно полезно для отладки сетевого взаимодействия:

• SIO_GET_TX_TIMESTAMP — получать метки времени для передаваемых пакетов. Входной параметр — идентификатор метки uint32_t, выходной параметр — метка времени uint64_t. Работает только на дейтаграммных сокетах.

• SIO_TIMESTAMPING — изменить настройки получения меток времени.

Вызов SIO_TIMESTAMPING принимает в качестве параметра указатель на структуру:

#include <mstcpip.h>

typedef struct _TIMESTAMPING_CONFIG

{

// Направление, для которого применяются метки.

ULONG  Flags;

// Число буферизованных меток.

USHORT TxTimestampsBuffered;

} TIMESTAMPING_CONFIG, *PTIMESTAMPING_CONFIG;

Флаги направления могут иметь следующие значения:

• TIMESTAMPING_FLAG_RX — метки принятых дейтаграмм.

• TIMESTAMPING_FLAG_TX — метки отправленных дейтаграмм.

Вызов задает число меток, которые можно принять. Если места в буфере нет, новые метки будут отброшены.

Для работы с временными метками в IP Helper API есть несколько функций.

Резервирование портов

Некоторые приложения должны работать только на портах из определенного диапазона. Для предоставления такой возможности ОС Windows имеет несколько ioctl и группу функций в библиотеке IP Helper, которые управляют резервированием портов.

Резервирование портов TCP или UDP выполняется следующими ioctl:

• SIO_ACQUIRE_PORT_RESERVATION — зарезервировать порты TCP или UDP для вызывающего приложения. Может резервировать как определенный порт для работы, так и любой доступный порт или диапазон портов.

• SIO_ASSOCIATE_PORT_RESERVATION — связать сокет с зарезервированным блоком портов TCP или UDP. Этот ioctl должен быть вызван до привязки сокета. Если к сокету уже привязан адрес и порт, назначенный ему порт будет выбран из резерва портов, определенного заданным токеном. Если из указанного резерва нет доступных портов, вызов функции привязки адреса завершится ошибкой.

• SIO_RELEASE_PORT_RESERVATION — освободить зарезервированные ранее порты TCP или UDP.

Следующие функции IP Helper API создают резервирование в постоянном хранилище и возвращают токен резервирования:

#include <iphlpapi.h>

// Для TCP-портов.

ULONG CreatePersistentTcpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts,

PULONG64 Token

);

// Для UDP-портов.

ULONG CreatePersistentUdpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts,

PULONG64 Token

);

Этот токен приложение должно передавать в рассмотренные ioctl, чтобы зарезервировать диапазон портов. Из этого диапазона оно затем может получить какой-либо порт.

Чтобы знать токен, приложение должно, например, сохранять его в настройках или получать, основываясь на диапазоне портов, используя функции:

// Для TCP-портов.

ULONG LookupPersistentTcpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts,

PULONG64 Token

);

// Для UDP-портов.

ULONG LookupPersistentUdpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts,

PULONG64 Token

);

Для удаления резервирования из постоянного хранилища используются следующие функции:

// Для TCP-портов.

ULONG DeletePersistentTcpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts

);

// Для UDP-портов.

ULONG DeletePersistentUdpPortReservation(

USHORT StartPort,

USHORT NumberOfPorts

);

Все функции выше при успешном завершении вернут код ошибки NO_ERROR.

Работа с IP-адресами

Для работы с IP-адресами используются следующие ioctl:

• SIO_ADDRESS_LIST_QUERY — получает список всех IP-адресов, которые сокет может использовать для прослушивания. При обычной настройке система имеет два IP-адреса: локальной петли — 127.0.0.1 — и сетевого интерфейса. Однако можно иметь как несколько интерфейсов, так и несколько IP-адресов на одном интерфейсе. Данный ioctl вернет структуру SOCKET_ADDRESS_LIST, которую мы уже рассматривали.

• SIO_ADDRESS_LIST_SORT — принимает SOCKET_ADDRESS_LIST, возвращенную из команды SIO_ADDRESS_LIST_QUERY, и сортирует адреса, а также заполняет соответствующие идентификаторы области действия адресов IPv6.

• SIO_ADDRESS_LIST_CHANGE — с помощью этого вызова можно зарегистрироваться на уведомление FD_ADDRESS_LIST_CHANGE о смене IP-адресов в системе. Уведомление может быть получено функциями WSAEventSelect() или WSAAsyncSelect().

Настройки UDP

Вызовы для UDP позволяют изменить обработку ошибок сети:

• SIO_UDP_CONNRESET — переключить распространение ошибок PORT_UNREACHABLE на отправляющий UDP-сокет. По умолчанию любые ошибки ICMP, генерируемые трафиком, отправленным через сокет UDP, не приводят к ошибкам при работе с сокетом. Например, если данные отправляются абоненту, который их не ожидает, по ICMP может быть отправлена ошибка PORT_UNREACHABLE. Через этот вызов можно включить распространение ошибки на отправляющий сокет. Приход ICMP-пакета приведет к ошибке WSAECONNRESET при вызове функции приема данных.

• SIO_UDP_NETRESET — переключить распространение ошибок NET_UNREACHABLE на отправляющий UDP-сокет. Эти ошибки могут быть отправлены по ICMP, когда истек TTL дейтаграммы. В остальном аналогичен вызову SIO_UDP_CONNRESET.

Настройки TCP

Часть настроек TCP выполняется через такие вызовы ioctl, как:

• SIO_KEEPALIVE_VALS — задать время ожидания и интервал проверки активности TCP-соединения. Делает то же, что и опция SO_KEEPALIVE, но переопределяет интервал, который задан в реестре.

• SIO_LOOPBACK_FAST_PATH — настроить TCP-сокет для уменьшения задержки. Ускоряет операции на loopback-интерфейсе. Включение оптимизации приводит к тому, что пакеты проходят только через транспортный уровень вместо традиционной петли, которая требует работы сетевого уровня. Также выключается алгоритм избежания перегрузок.

• SIO_TCP_INITIAL_RTO — установить начальные параметры повторной передачи: начальный RTT и число передач SYN.

• SIO_TCP_INFO — получить статистику TCP для сокета. В отличие от получения статистики TCP с помощью функции GetPerTcpConnectionEStats() не требует загрузки и фильтрации таблицы подключений TCP, выполняемых с повышенными привилегиями. В зависимости от входного параметра, 0 или 1, вернет структуры TCP_INFO_v0 или TCP_INFO_v1 из mstcpip.h. Мы не будем их рассматривать: они подробно описаны в MSDN.

• SIO_TCP_SET_ICW — задать Initial Congestion Window, то есть начальное окно для алгоритма предотвращения коллизий, ограничивающее количество данных в первом RTT. На клиенте позволяет задать 2, 4 или 10 MSS, на сервере — до 64 MSS. До ОС Windows 10 и Windows Server 2012 R2 начальное окно по умолчанию равно 4 MSS. После — 10 MSS.

• SIO_TCP_SET_ACK_FREQUENCY — задать число отложенных подтверждений ACK. То же, что опция TCP_QUICKACK в Linux, но, будучи один раз включенным, режим не сбрасывается. Если установить 1, режим отложенных подтверждений будет выключен.

Вызов SIO_KEEPALIVE_VALS принимает в качестве входного параметра указатель на структуру:

struct tcp_keepalive

{

// Включить или выключить keep-alive.

u_long  onoff;

// Тайм-аут в случае ответа по TCP.

u_long  keepalivetime;

// Интервал отправки пакетов в случае отсутствия ответа.

u_long  keepaliveinterval;

};

SIO_TCP_INITIAL_RTO принимает структуру:

#include <mstcpip.h>

typedef struct _TCP_INITIAL_RTO_PARAMETERS

{

// Начальное значение RTT в миллисекундах.

USHORT Rtt;

// Максимальное количество передач SYN.

UCHAR  MaxSynRetransmissions;

} TCP_INITIAL_RTO_PARAMETERS, *PTCP_INITIAL_RTO_PARAMETERS;

Поле Rtt также может принимать следующие значения:

• TCP_INITIAL_RTO_UNSPECIFIED_RTT — использовать заданные администратором настройки.

• TCP_INITIAL_RTO_DEFAULT_RTT — использовать системные значения по умолчанию.

Точно так же поле MaxSynRetransmissions может принимать сходные значения:

• TCP_INITIAL_RTO_UNSPECIFIED_MAX_SYN_RETRANSMISSIONS — использовать заданное администратором значение.

• TCP_INITIAL_RTO_DEFAULT_MAX_SYN_RETRANSMISSIONS — системное значение по умолчанию.

Значение по умолчанию для SIO_TCP_SET_ACK_FREQUENCY задается в ключе реестра

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\<Interface GUID>\TcpAckFrequency.

Буквально это количество ожидающих подтверждений до того, как таймер отложенного подтверждения будет проигнорирован. Значение по умолчанию — 2, максимальное — 255. 1 включает подтверждение каждого пакета, 0 трактуется как значение по умолчанию.

Широковещательный трафик

Широковещательным трафиком управляют несколько опций, позволяющих его принимать или получать корректный адрес для его отправки:

• SIO_GET_BROADCAST_ADDRESS — получить широковещательный адрес для использования в функциях sendto() и WSASendTo().

• SIO_RCVALL — включить получение всех пакетов, которые проходят через сетевой интерфейс. Принимает дескриптор сокета. Тип сокета должен быть SOCK_RAW. Работает и для IPv6, в этом случае протоколом сокета должен быть указан IPPROTO_IPV6. Вызов принимает несколько значений:

• RCVALL_OFF — выключить неразборчивый режим.

• RCVALL_ON — включить неразборчивый режим. Адаптер начнет принимать все пакеты.

• RCVALL_SOCKETLEVELONLY — нереализованный режим.

• RCVALL_IPLEVEL или RCVALL_MAX — адаптер будет принимать только кадры, направленные ему. Он не переходит в неразборчивый режим. Но IP-уровень стека перенаправляет все пакеты IPv4 и IPv6 на уровень пользователя.

• SIO_RCVALL_IF — получать все пакеты с заданного интерфейса. Принимает индекс сетевого интерфейса.

Внимание! Размер буфера для вызова SIO_RCVALL должен иметь значение sizeof(RCVALL_VALUE).

Иногда в коде передают значение true вызову SIO_RCVALL. Это работает из-за того, что константа RCVALL_ON имеет значение 1. Но лучше так не делать, поскольку с точки зрения семантики вызова подобное значение некорректно.

Многоадресная рассылка

Хотя многоадресную рассылку в книге мы не рассматриваем, здесь мы перечислим ioctl для управления ею:

• SIO_MULTICAST_SCOPE — указывает области, по которым будет осуществляться многоадресная рассылка. Область определяется как количество сегментов перенаправленной сети, которые должны быть охвачены. По умолчанию 1.

• SIO_RCVALL_IGMPMCAST — позволяет сокету получать весь многоадресный IP-трафик IGMP в сети. Остальной трафик игнорируется. Сокет должен быть создан с указанием протокола IPPROTO_IGMP.

• SIO_RCVALL_MCAST — позволяет сокету получать весь многоадресный IP-трафик в сети. То же, что и SIO_RCVALL, но возвращается только многоадресный трафик IPv4 с IP-адресов в диапазоне от 224.0.0.0 до 239.255.255.255, а не все IP-пакеты.

• SIO_RCVALL_MCAST_IF — задать интерфейс для входящего многоадресного трафика.

• SIO_INDEX_MCASTIF — задать индекс интерфейса для исходящего многоадресного трафика.

• SIO_INDEX_ADD_MCAST — добавить интерфейс с заданным индексом в группу многоадресной рассылки. Входной параметр — структура ip_mreq, в которой поле imr_interface содержит индекс интерфейса.

• SIO_INDEX_DEL_MCAST — удалить интерфейс из группы многоадресной рассылки.

• SIO_MULTIPOINT_LOOPBACK — определить, будут ли опубликованы данные, отправленные приложением на интерфейс, в сеансе многоадресной рассылки. По умолчанию для IP данные публикуются как входящие в очереди приема сокета при условии, что сокет является членом группы многоадресной рассылки. Данная опция позволяет отключить это поведение.

Структура ip_mreq:

#include <ws2ipdef.h>

typedef struct ip_mreq

{

// IPv4-адрес группы многоадресной рассылки.

IN_ADDR imr_multiaddr;

// В зависимости от SIO индекс либо IPv4-адрес интерфейса.

IN_ADDR imr_interface;

} IP_MREQ, *PIP_MREQ;

Управление фильтром адресов многоадресной рассылки

Эти ioctl можно использовать для установки состояния многоадресной рассылки в одном вызове вместо нескольких вызовов IP_ADD_SOURCE_MEMBERSHIP, IP_DROP_SOURCE_MEMBERSHIP или IP_BLOCK_SOURCE и IP_UNBLOCK_SOURCE:

• SIO_SET_MULTICAST_FILTER или SIOCSIPMSFILTER — установить состояние многоадресной рассылки.

• SIO_GET_MULTICAST_FILTER или SIOCGIPMSFILTER — извлечь набор фильтров многоадресной рассылки для данного сокета.

Второй идентификатор этих опций — их более новое обозначение. Входным параметром является структура ip_msfilter, которая определяется следующим образом:

#include <ws2ipdef.h>

struct ip_msfilter

{

// Адрес многоадресной рассылки.

struct in_addr imsf_multiaddr;

// Локальный интерфейс для присоединения к группе.

struct in_addr imsf_interface;

// Включено или исключено состояние фильтра, что указывается

// с помощью определений MCAST_INCLUDE и MCAST_EXCLUDE соответственно.

u_long imsf_fmode;

// Количество исходных адресов IPv4 в массиве imsf_slist.

u_long imsf_numsrc;

// Массив адресов.

struct in_addr imsf_slist[1];

};

Для работы с данными ioctl требуется сеть, в которой узлы обеспечивают поддержку IGMPv3 и ОС Windows не ниже XP. Существует несколько удобных функций, упрощающих работу с данными ioctl.

Функции для установки и получения фильтров IPv4:

#include <ws2tcpip.h>

// Установить фильтр для IPv4.

int setipv4sourcefilter(

SOCKET Socket,

IN_ADDR Interface,

IN_ADDR Group,

MULTICAST_MODE_TYPE FilterMode,

ULONG SourceCount,

const IN_ADDR *SourceList

);

// Получить фильтр для IPv4.

int getipv4sourcefilter(

SOCKET Socket,

IN_ADDR Interface,

IN_ADDR Group,

MULTICAST_MODE_TYPE *FilterMode,

ULONG *SourceCount,

IN_ADDR *SourceList

);

Универсальные функции, которые работают как с IPv4, так и с IPv6:

// Установить фильтр.

int setsourcefilter(

SOCKET Socket,

ULONG Interface,

const SOCKADDR *Group,

int GroupLength,

MULTICAST_MODE_TYPE FilterMode,

ULONG SourceCount,

const SOCKADDR_STORAGE *SourceList);

// Получить фильтр.

int getsourcefilter(

SOCKET Socket,

ULONG Interface,

const SOCKADDR *Group,

int GroupLength,

MULTICAST_MODE_TYPE *FilterMode,

ULONG *SourceCount,

SOCKADDR_STORAGE *SourceList

);

Мы не будем описывать их подробно: смысл параметров несложно понять; кроме того, они документированы в MSDN и мы не разбираем детали фильтрации.

QoS

Опции для работы с QoS в основном устарели и существуют для обеспечения совместимости:

• SIO_CHK_QOS — получить информацию о характеристиках трафика QoS.

• SIO_GET_GROUP_QOS — получить структуру QoS, связанную с группой сокетов, к которой принадлежит этот сокет.

• SIO_GET_QOS — получить структуры QoS, связанные с сокетом.

• SIO_SET_GROUP_QOS — зарезервирована.

• SIO_SET_QOS — связать структуры QoS с сокетом.

• SIO_SET_PRIORITY_HINT — установить приоритет трафика для данного сокета. Опция не влияет на входящий трафик, однако исходящие пакеты будут формироваться с учетом данного приоритета. Пакеты с высоким приоритетом будут отправлены раньше.

ioctl для WFP

Для управления платформой фильтрации существует несколько ioctl:

• SIO_QUERY_WFP_CONNECTION_REDIRECT_CONTEXT — получить контекст перенаправления принятого подключения TCP/IP.

• SIO_QUERY_WFP_CONNECTION_REDIRECT_RECORDS — получить запись перенаправления принятого подключения TCP/IP.

• SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS — задать запись перенаправления принятого подключения TCP/IP.

• SIO_QUERY_WFP_ALE_ENDPOINT_HANDLE — запросить дескриптор конечной точки ALE.

ALE143 — это набор уровней WFP в режиме ядра, которые используются для фильтрации с отслеживанием состояния. Он показан на рис. 18.3.

Рис. 18.3. ALE

ioctl для повышения безопасности

Следующие функции позволяют включать политику безопасности сокета. Мы приведем их для справки, так как ограничены объемом книги:

• SIO_SET_SECURITY — установить параметры безопасности сокета.

• SIO_QUERY_SECURITY — получить параметры безопасности сокета.

Вызов SIO_SET_SECURITY делает то же, что и следующая функция:

#include <Ws2tcpip.h>

int WSASetSocketSecurity(

SOCKET Socket,

const SOCKET_SECURITY_SETTINGS* SecuritySettings,

ULONG SecuritySettingsLen,

LPWSAOVERLAPPED Overlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE CompletionRoutine

);

Она принимает структуру, которая содержит параметры безопасности:

#include <mstcpip.h>

typedef enum _SOCKET_SECURITY_PROTOCOL

{

SOCKET_SECURITY_PROTOCOL_DEFAULT,

SOCKET_SECURITY_PROTOCOL_IPSEC,

SOCKET_SECURITY_PROTOCOL_IPSEC2,

SOCKET_SECURITY_PROTOCOL_INVALID

} SOCKET_SECURITY_PROTOCOL;

typedef struct _SOCKET_SECURITY_SETTINGS

{

// Идентификатор протокола безопасности.

SOCKET_SECURITY_PROTOCOL securityProtocol;

// Флаги, задающие уровень безопасности.

ULONG securityFlags;

} SOCKET_SECURITY_SETTINGS;

Атрибут securityProtocol может принимать следующие значения:

• SOCKET_SECURITY_PROTOCOL_DEFAULT — используется политика безопасности по умолчанию.

• SOCKET_SECURITY_PROTOCOL_IPSEC — используется IPsec.

• SOCKET_SECURITY_PROTOCOL_INVALID — политика безопасности не используется.

Следующие флаги позволяют задать параметры. На данный момент это режим шифрования:

• SOCKET_SETTINGS_GUARANTEE_ENCRYPTION — требуется гарантированное шифрование трафика. Флаг указывает включить шифрование, даже если политика по умолчанию предпочитает этого не делать. Если этот флаг установлен, но шифрование невозможно, соединение установлено не будет.

• SOCKET_SETTINGS_ALLOW_INSECURE — разрешить обмен данными без шифрования. Если флаг не установлен, пакеты не будут отправляться без шифрования.

Для упрощения работы с вызовом SIO_QUERY_SECURITY существует следующая функция:

#include <mstcpip.h>

int WSAAPI WSAQuerySocketSecurity(

SOCKET Socket,

const SOCKET_SECURITY_QUERY_TEMPLATE *SecurityQueryTemplate,

ULONG SecurityQueryTemplateLen,

SOCKET_SECURITY_QUERY_INFO *SecurityQueryInfo,

ULONG *SecurityQueryInfoLen,

LPWSAOVERLAPPED Overlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE CompletionRoutine

);

Она принимает шаблон того, что требуется вернуть, и возвращает структуру SOCKET_SECURITY_QUERY_INFO:

#include <mstcpip.h>

typedef struct _SOCKET_SECURITY_QUERY_TEMPLATE

{

// Протокол, используемый для защиты трафика.

SOCKET_SECURITY_PROTOCOL SecurityProtocol;

// IP-адрес однорангового узла, для которого запрашиваются

// сведения о безопасности.

SOCKADDR_STORAGE PeerAddress;

// Маска доступа, используемая для возврата токенов учетной записи и узла.

// Может быть равна 0, если данные поля не требуются.

ULONG PeerTokenAccessMask;

} SOCKET_SECURITY_QUERY_TEMPLATE;

typedef struct _SOCKET_SECURITY_QUERY_INFO

{

// Протокол защиты трафика.

SOCKET_SECURITY_PROTOCOL SecurityProtocol;

// Флаги.

ULONG Flags;

// Дескриптор токена доступа учетной записи, под которой

// выполняется приложение.

UINT64 PeerApplicationAccessTokenHandle;

// Дескриптор токена доступа учетной записи пользователя.

UINT64 PeerMachineAccessTokenHandle;

} SOCKET_SECURITY_QUERY_INFO;

Если были возвращены дескрипторы токенов, они должны быть закрыты.

Возвращаемые флаги могут иметь следующие значения:

• SOCKET_INFO_CONNECTION_SECURED — трафик защищается протоколом безопасности.

• SOCKET_INFO_CONNECTION_ENCRYPTED — трафик шифруется. При наличии этого флага SOCKET_INFO_CONNECTION_SECURED установлен всегда.

Следующие вызовы позволяют указать или удалить SPN — Service Principal Name, уникальный идентификатор экземпляра службы:

• SIO_SET_PEER_TARGET_NAME — установить SPN для сокета.

• SIO_DELETE_PEER_TARGET_NAME — удалить SPN для сокета.

SPN используется Kerberos для связи экземпляра службы с учетной записью, под которой эта служба используется, и позволяет клиентскому приложению запросить проверку подлинности службы для учетной записи, даже если у клиента нет имени учетной записи.

В свою очередь, Kerberos используется для аутентификации и авторизации в Active Directory. Подробнее о нем и о SPN можно узнать в разделе MSDN "Service principal names"144.

Функции WSASetSocketPeerTargetName() и WSADeleteSocketPeerTargetName() позволяют упростить добавление и удаление SPN:

#include <Ws2tcpip.h>

int WSASetSocketPeerTargetName(

SOCKET Socket,

const SOCKET_PEER_TARGET_NAME* PeerTargetName,

ULONG PeerTargetNameLen,

LPWSAOVERLAPPED Overlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE CompletionRoutine

);

int WSADeleteSocketPeerTargetName(

SOCKET Socket,

const struct sockaddr* PeerAddr,

ULONG PeerAddrLen,

LPWSAOVERLAPPED Overlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE CompletionRoutine

);

Этим функциям необходимо передать дескриптор сокета, описание SPN и необязательные параметры для асинхронного выполнения.

SPN описывается следующей структурой:

#include <mstcpip.h>

typedef struct _SOCKET_PEER_TARGET_NAME

{

// Протокол, используемый для защиты трафика.

SOCKET_SECURITY_PROTOCOL SecurityProtocol;

// IP-адрес узла.

SOCKADDR_STORAGE PeerAddress;

// Размер структуры, включая имя в массиве AllStrings.

ULONG PeerTargetNameStringLen;

// SPN.

wchar_t AllStrings[0];

} SOCKET_PEER_TARGET_NAME;

Функции возвращают 0 в случае успеха или SOCKET_ERROR в случае ошибки.

Прочие вызовы

Изменение параметров транспорта, совместимости и расширений:

• SIO_QUERY_TRANSPORT_SETTING — получить параметр транспорта сокета. Только для TCP-сокетов.

• SIO_APPLY_TRANSPORT_SETTING — применить параметр транспорта к сокету:

• REAL_TIME_NOTIFICATION_CAPABILITY — запрос на установку настроек уведомлений реального времени. Единственный параметр транспорта, определенный для TCP-сокетов в ОС Windows 8 и ОС Windows Server 2012.

• ASSOCIATE_NAMERES_CONTEXT — хэндл FQDN, на основе которого будет применяться политика. Определен для ОС Windows 10 и ОС Windows Server 2016.

• SIO_GET_EXTENSION_FUNCTION_POINTER — получить указатель на функцию расширения, которую поддерживает сокетный провайдер. Как входной параметр вызов принимает известный GUID. Например, по GUID, равному WSAID_ACCEPTEX, он вернет указатель на функцию AcceptEx(). В современных ОС Windows для провайдеров от Microsoft использовать данный механизм обычно не требуется, но он позволяет не компоноваться с лишней DLL, обычно mswsock.dll, и убрать один промежуточный вызов функции.

• SIO_GET_MULTIPLE_EXTENSION_FUNCTION_POINTER — получить указатели на функции расширения ввода-вывода. Используется конкретно для получения указателей на функции RIO — зарегистрированного ввода-вывода, рассмотренного в книге 2. Входной параметр — GUID, равный WSAID_MULTIPLE_RIO. Выходной буфер должен содержать указатель на RIO_EXTENSION_FUNCTION_TABLE — таблицу с адресами функций RIO.

• SIO_SET_COMPATIBILITY_MODE — настроить поведение сетевого стека по умолчанию в зависимости от версии Windows. Можно настроить разное поведение в ответ на уменьшение размера буфера приема TCP или максимального размера окна. Весьма интересный ioctl, позволяющий, например, проверить, как будет работать сеть в приложении на разных версиях ОС Windows, запустив его только на старшей версии. Реально же используется для установки режимов совместимости, предоставляемых ОС для запуска приложений, которые в текущей версии работают некорректно.

• SIO_PRIORITY_HINT — необходим для управления алгоритмом LEDBAT. Принимает значение типа PRIORITY_STATUS — структуры, которая содержит два 32-битных поля: для отправителя и для получателя. Подробнее описан в статье «LEDBAT Background Data Transfer for Windows»145.

Для параметра вызова SIO_QUERY_TRANSPORT_SETTING используется GUID параметра в структуре:

#include <transportsettingcommon.h>

typedef struct TRANSPORT_SETTING_ID

{

GUID Guid;

} TRANSPORT_SETTING_ID, *PTRANSPORT_SETTING_ID;

Для SIO_APPLY_TRANSPORT_SETTING структура идентификатора всегда находится перед данными, передаваемыми как параметр вызова.

Например, запрос REAL_TIME_NOTIFICATION_CAPABILITY вернет структуру REAL_TIME_NOTIFICATION_SETTING_OUTPUT:

#include <mstcpip.h>

typedef enum

{

CONTROL_CHANNEL_TRIGGER_STATUS_INVALID = 0,

CONTROL_CHANNEL_TRIGGER_STATUS_SOFTWARE_SLOT_ALLOCATED = 1,

CONTROL_CHANNEL_TRIGGER_STATUS_HARDWARE_SLOT_ALLOCATED = 2,

CONTROL_CHANNEL_TRIGGER_STATUS_POLICY_ERROR = 3,

CONTROL_CHANNEL_TRIGGER_STATUS_SYSTEM_ERROR = 4,

CONTROL_CHANNEL_TRIGGER_STATUS_TRANSPORT_DISCONNECTED = 5,

CONTROL_CHANNEL_TRIGGER_STATUS_SERVICE_UNAVAILABLE = 6

} CONTROL_CHANNEL_TRIGGER_STATUS, *PCONTROL_CHANNEL_TRIGGER_STATUS;

typedef struct _REAL_TIME_NOTIFICATION_SETTING_OUTPUT

{

CONTROL_CHANNEL_TRIGGER_STATUS ChannelStatus;

} REAL_TIME_NOTIFICATION_SETTING_OUTPUT,

*PREAL_TIME_NOTIFICATION_SETTING_OUTPUT;

Он принимает следующую структуру:

#include <mstcpip.h>

typedef struct _REAL_TIME_NOTIFICATION_SETTING_INPUT

{

// Идентификатор параметра транспорта.

TRANSPORT_SETTING_ID TransportSettingId;

// GUID события брокера для этого идентификатора транспорта.

GUID BrokerEventGuid;

} REAL_TIME_NOTIFICATION_SETTING_INPUT, *PREAL_TIME_NOTIFICATION_SETTING_INPUT;

Видим, что первый атрибут структуры — идентификатор параметра транспорта TRANSPORT_SETTING_ID, за которым следуют значения параметров.

В структуре ASSOCIATE_NAMERES_CONTEXT_INPUT первый атрибут структуры тоже идентификатор:

#include <mstcpip.h>

typedef struct _ASSOCIATE_NAMERES_CONTEXT_INPUT

{

// Идентификатор параметра транспорта.

TRANSPORT_SETTING_ID TransportSettingId;

// Хэндл FQDN.

UINT64 Handle;

} ASSOCIATE_NAMERES_CONTEXT_INPUT, *PASSOCIATE_NAMERES_CONTEXT_INPUT;

Аргумент вызова SIO_SET_COMPATIBILITY_MODE — структура:

#include <mswsock.h>

// Здесь определены константы.

#include <Sdkddkver.h>

typedef struct _WSA_COMPATIBILITY_MODE

{

// Запрашиваемое поведение.

WSA_COMPATIBILITY_BEHAVIOR_ID BehaviorId;

// Версия NTDDI, используемого в заданной версии ОС.

ULONG TargetOsVersion;

} WSA_COMPATIBILITY_MODE, *PWSA_COMPATIBILITY_MODE;

Значения типа поведения:

• WsaBehaviorReceiveBuffering — для поля версии больше NTDDI_LONGHORN допускается уменьшение размера приемного буфера TCP в этом сокете с использованием параметра сокета SO_RCVBUF после установки TCP-соединения. Если для атрибута TargetOsVersion установлено значение, предшествующее Windows Vista, уменьшение размера приемного буфера TCP в этом сокете с использованием параметра сокета SO_RCVBUF не допускается после установки соединения.

• WsaBehaviorAutoTuning — для поля версии больше NTDDI_LONGHORN включается автонастройка окна приема. Коэффициент масштабирования окна TCP уменьшается до 2 по сравнению со значением по умолчанию, равным 8. Для меньших версий автонастройка окна приема и опция масштабирования окна TCP отключены, а максимальный размер окна приема ограничен 65 535 байтами.

• WsaBehaviorAll — запросить все возможные варианты поведения, определенные для WSA_COMPATIBILITY_BEHAVIOR_ID.

Значения поля версии:

• NTDDI_LONGHORN — Windows Vista;

• NTDDI_WS03 — Windows Server 2003;

• NTDDI_WINXP — Windows XP;

• NTDDI_WIN2K — Windows 2000.

Мы перечислили только константы, указанные в MSDN. На практике их значительно больше: NTDDI_WS08, NTDDI_WS08SP2, NTDDI_WS08SP3, NTDDI_WS08SP4, NTDDI_WIN7, NTDDI_WIN8, NTDDI_WINBLUE и т.д.

Управление дескриптором

Сокет в системе адресуется дескриптором или хэндлом, поэтому для него работают функции, применимые для всех дескрипторов. Их функциональность частично пересекается с функцией fcntl(), отсутствующей в ОС Windows.

Определить, что дескриптор является сокетом, позволит функция GetFileType():

#include <fileapi.h>

DWORD GetFileType(HANDLE hFile);

Результат FILE_TYPE_PIPE говорит о том, что дескриптор ссылается на сокет, именованный либо анонимный канал.

Получить или установить флаги дескриптора можно, используя следующие функции:

#include <handleapi.h>

// Получить флаги дескриптора.

bool GetHandleInformation(HANDLE hObject, LPDWORD lpdwFlags);

// Установить флаги дескриптора.

bool SetHandleInformation(HANDLE hObject, DWORD dwMask, DWORD dwFlags);

Параметры функций GetHandleInformation() и SetHandleInformation():

• hObject — дескриптор.

• lpdwFlags — указатель на переменную для получения флагов.

• dwMask — маска установки флагов. Устанавливаются и сбрасываются только флаги, бит которых в маске равен 1.

• dwFlags — флаги для установки.

Функции возвращают истину в случае успеха и ложь в случае неудачи. Если бит флага установлен и в маске, и в dwFlags, флаг устанавливается, если только в маске — флаг сбрасывается.

Флаги могут принимать следующие значения:

• HANDLE_FLAG_INHERIT — означает, что новый процесс, который создается через вызов CreateProcess(), унаследует этот дескриптор.

• HANDLE_FLAG_PROTECT_FROM_CLOSE — дескриптор нельзя закрыть функцией CloseHandle().

Следовательно, вызов функции GetHandleInformation() позволяет определить, был ли создан сокет с установленным флагом WSA_FLAG_NO_HANDLE_INHERIT.

Проблемы, связанные с наследованием дескрипторов, рассмотрены в главе 23.

Так же как в Unix-подобных ОС, дескриптор только идентифицирует объект ядра «сокет». При этом дескрипторов может быть несколько.

Иногда требуется создать второй экземпляр дескриптора в другом процессе. В Unix-подобных системах для передачи дескрипторов существует сообщение SCM_RIGHTS, описанное в главе 9. В ОС Windows обычно для этого используется функция DuplicateHandle() из handleapi.h. Она подходит для почтовых ящиков и каналов. Однако для сокетов она не подойдет — для них следует использовать WSADuplicateSocket():

#include <winsock2.h>

int WSAAPI WSADuplicateSocket(

SOCKET s,

DWORD dwProcessId,

LPWSAPROTOCOL_INFO lpProtocolInfo

);

Параметры функции WSADuplicateSocket():

• s — дескриптор сокета.

• dwProcessId — PID процесса, в который будет произведено дублирование.

• lpProtocolInfo — указатель на буфер, в который функция запишет структуру WSAPROTOCOL_INFO.

В случае успеха функция возвращает 0.

DuplicateHandle() некорректно выполняет подсчет ссылок на объект сокета, поэтому для WinSock используется отдельная функция.

Внимание! На дублируемых сокетах QoS должен быть выключен!

После вызова этой функции необходимо передать структуру, на которую указывает lpProtocolInfo, в процесс, куда был клонирован дескриптор, и затем вызвать с ней функцию WSASocket() в целевом процессе.

Пример вызова:

...

STARTUPINFO si_parent = { .cb = sizeof(STARTUPINFO) };

PROCESS_INFORMATION pi_child;

// Создать новый процесс.

if (CreateProcess(nullptr, exec_name, nullptr, nullptr, false,

CREATE_NEW_CONSOLE, nullptr, nullptr, &si_parent,

&pi_child))

{

// После инициализации передать экземпляр в дочерний процесс.

WSAPROTOCOL_INFO protocol_info;

// Получить информацию о протоколе, которая используется при

// дублировании сокета.

if (SOCKET_ERROR == WSADuplicateSocket(client_sock, pi_child.dwProcessId,

&protocol_info))

{

std::cerr

<< "WSADuplicateSocket(): failed. "

<< "Error = " << WSAGetLastError());

throw ...;

}

// Далее передать экземпляр структуры в дочерний процесс.

...

Часто для передачи используют отображаемый в память файл, но подходит любой удобный способ межпроцессного обмена, позволяющий отправить структуру.

Чтобы продублировать сокет, функции WSASocket() нужно передать экземпляр структуры WSAPROTOCOL_INFO, а вместо семейства адресов, типа и протокола подставить константу FROM_PROTOCOL_INFO:

// Получить структуру, например, через общую память.

auto h_mapping = OpenFileMapping(FILE_MAP_READ, false, "pi_mapping");

auto mm_view = MapViewOfFile(h_mapping, FILE_MAP_READ, 0, 0, 0);

if (mm_view)

{

// Инициализировать структуры из переданной.

memcpy(&protocol_info, mm_view, sizeof(WSAPROTOCOL_INFO));

UnmapViewOfFile(mm_view);

// Создать для описанного сокета новый дескриптор.

SOCKET duplicated_sock = WSASocket(FROM_PROTOCOL_INFO,

FROM_PROTOCOL_INFO,

FROM_PROTOCOL_INFO, &protocol_info,

0, 0);

// Тут может использоваться созданный дескриптор.

}

В Python на основе данной функции реализованы методы сокета share() и from_share():

def socket.share(process_id)

def socket.fromshare(data)

Первый метод дублирует сокет в процесс с идентификатором process_id и возвращает некоторый байтовый объект, который нужно передать в целевой процесс и вызвать с ним метод from_share().

Также в Python существует метод socket.dup(), который дублирует сокетный дескриптор в текущем процессе:

def socket.dup()

Он вызывает функцию с его идентификатором:

#ifdef MS_WINDOWS

WSAPROTOCOL_INFOW info;

...

if (WSADuplicateSocketW(fd, GetCurrentProcessId(), &info))

return set_error();

newfd = WSASocketW(FROM_PROTOCOL_INFO, FROM_PROTOCOL_INFO,

FROM_PROTOCOL_INFO, &info, 0, WSA_FLAG_OVERLAPPED);

Выше мы привели основные функции, которые реализуют специфическое поведение для сокетов. К сокетам применимы и некоторые другие функции, работающие с дескрипторами. Но они не являются частью сетевого WinAPI, и мы их не описываем.

Управление сетевыми устройствами

На рис. 18.4 показано, как в ОС Windows производится системный вызов, например, к драйверам устройств.

Рис. 18.4. Схема вызова функции драйвера

В пользовательском API для его инициирования существует следующая функция:

#include <ioapiset.h>

bool DeviceIoControl(

HANDLE hDevice,

DWORD dwIoControlCode,

LPVOID lpInBuffer,

DWORD nInBufferSize,

LPVOID lpOutBuffer,

DWORD nOutBufferSize,

LPDWORD lpBytesReturned,

LPOVERLAPPED lpOverlapped

);

Параметры функции DeviceIoControl():

• hDevice — дескриптор сетевого адаптера либо его имя в ОС Windows 2000 и более новых версиях.

• dwIoControlCode — код вызова.

• lpInBuffer — указатель на буфер, содержащий OID запроса, который использует NDIS.

• nInBufferSize — размер входного буфера.

• lpOutBuffer — буфер, который получает данные, возвращенные NDIS.

• nOutBufferSize — размер выходного буфера.

• lpBytesReturned — указатель на переменную, которая будет содержать количество байтов, полученных в lpOutBuffer.

• lpOverlapped — структура для перекрывающегося ввода-вывода.

Функция возвращает истину в случае успеха.

Дескриптор сетевого адаптера можно получить, вызвав функцию CreateFile().

Как мы уже видели в главе 17, функция CreateFile() не только отвечает за работу с файлами, но и позволяет «открывать» почтовые ящики, каналы, а также устройства ввода-вывода, в том числе коммуникационные. Например, порт: "\\.\COM10".

Открыть сетевое устройство можно по его GUID, например: "\\?\ROOT#NET#0002#{cac88484-7515-4c03-82e6-71a87abac361}\ovpn-dco".

Функция вернет дескриптор, который можно использовать в DeviceIoControl().

Для универсального интерфейса сетевых драйверов NDIS самым востребованным вызовом является IOCTL_NDIS_QUERY_GLOBAL_STATS.

Он служит для получения различных данных об устройстве.

Список сетевых адаптеров хранится в ключах реестра

"HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\NetworkCards\Nnn"

Значения этих переменных могут быть использованы, чтобы получить дескриптор адаптера, для чего его необходимо передать как имя файла в CreateFile().

Внимание! Начиная с Windows Vista, сетевая подсистема ядра перешла с NDIS 5.1 на NDIS 6, в котором данный вызов объявлен устаревшим.

Управление сетевыми адаптерами

Адаптеры описываются списком структур IP_ADAPTER_INFO, по одной для каждого адаптера на локальном узле:

#include <iptypes.h>

typedef struct _IP_ADAPTER_INFO

{

// Следующий адаптер в списке.

struct _IP_ADAPTER_INFO *Next;

// Зарезервировано.

DWORD ComboIndex;

// Имя адаптера.

char AdapterName[MAX_ADAPTER_NAME_LENGTH + 4];

// Текстовое описание адаптера.

char Description[MAX_ADAPTER_DESCRIPTION_LENGTH + 4];

// Длина MAC-адреса.

UINT AddressLength;

// MAC-адрес.

BYTE Address[MAX_ADAPTER_ADDRESS_LENGTH];

// Индекс адаптера.

DWORD Index;

// IANA-тип адаптера.

UINT Type;

// Флаг включения DHCP.

UINT DhcpEnabled;

// Зарезервировано.

PIP_ADDR_STRING CurrentIpAddress;

// Список IP-адресов данного адаптера.

IP_ADDR_STRING IpAddressList;

// IP-адреса шлюзов.

IP_ADDR_STRING GatewayList;

// Адреса DHCP-серверов.

IP_ADDR_STRING DhcpServer;

// Использует ли адаптер WINS.

BOOL HaveWins;

// Основной сервер WINS.

IP_ADDR_STRING PrimaryWinsServer;

// Вторичный сервер WINS.

IP_ADDR_STRING SecondaryWinsServer;

// Время получения от DHCP-сервера текущего адреса и других параметров.

time_t LeaseObtained;

// Время истечения данных, полученных от DHCP-сервера.

time_t LeaseExpires;

} IP_ADAPTER_INFO, *PIP_ADAPTER_INFO;

Структуру IP_ADDR_STRING мы опишем далее. Она содержит адрес, маску и указатель на следующий элемент списка.

Функция GetAdaptersInfo() возвращает список адаптеров:

#include <iphlpapi.h>

ULONG GetAdaptersInfo(PIP_ADAPTER_INFO AdapterInfo, PULONG SizePointer);

В SizePointer будет записано количество элементов в списке AdapterInfo.

Чтобы получить дополнительные сведения о конкретном адаптере, используется функция GetPerAdapterInfo():

#include <iphlpapi.h>

typedef struct _IP_PER_ADAPTER_INFO_W2KSP1

{

// Включена ли автоматическая настройка адресов без DHCP — APIPA.

UINT AutoconfigEnabled;

// Настроен ли адрес с использованием APIPA.

UINT AutoconfigActive;

// Зарезервировано.

PIP_ADDR_STRING CurrentDnsServer;

// Список используемых DNS-серверов.

IP_ADDR_STRING DnsServerList;

} IP_PER_ADAPTER_INFO_W2KSP1, *PIP_PER_ADAPTER_INFO_W2KSP1;

DWORD GetPerAdapterInfo(ULONG IfIndex, PIP_PER_ADAPTER_INFO pPerAdapterInfo,

PULONG pOutBufLen);

Параметры функции GetPerAdapterInfo():

• IfIndex — индекс адаптера, для которого требуется получить информацию.

• pPerAdapterInfo — указатель на структуру IP_PER_ADAPTER_INFO для получения сведений об адаптере.

• pOutBufLen — размер выходного буфера.

APIPA — Automatic Private IP Addressing — то же самое, что и ZeroConf. Она позволяет создавать IP-сети без явной конфигурации.

Функции GetPerAdapterInfo() требуется указать индекс адаптера. Этот индекс по имени адаптера возвращает функция GetAdapterIndex():

DWORD GetAdapterIndex(LPWSTR AdapterName, PULONG IfIndex);

Параметры функции GetAdapterIndex():

• AdapterName — указатель на имя сетевого адаптера в кодировке UTF-8.

• IfIndex — указатель на возвращаемый индекс сетевого адаптера.

Функция GetAdaptersAddresses() позволяет получить IP-адреса, связанные с конкретным адаптером. Эта функция поддерживает адресацию как IPv4, так и IPv6:

ULONG GetAdaptersAddresses(ULONG Family, ULONG Flags, PVOID Reserved,

PIP_ADAPTER_ADDRESSES AdapterAddresses, PULONG SizePointer);

Параметры функции GetAdaptersAddresses():

• Family — извлекаемое семейство адресов:

• AF_UNSPEC — IPv4 и IPv6;

• AF_INET — IPv4;

• AF_INET6 — IPv6.

• Flags — флаги, определяющие извлекаемые поля.

• Reserved — зарезервированный параметр, который должен быть равен nullptr.

• AdapterAddresses — указатель на буфер, который будет содержать список структур IP_ADAPTER_ADDRESSES.

• SizePointer — размер буфера, содержащего информацию об адаптерах.

Значения флагов позволяют задать, какие параметры нужно вернуть, а какие пропустить.

Часть из них поддерживается большинством ОС:

• GAA_FLAG_SKIP_UNICAST — не возвращать адреса однонаправленных адаптеров.

• GAA_FLAG_SKIP_ANYCAST — не возвращать адреса IPv6 anycast.

• GAA_FLAG_SKIP_MULTICAST — не возвращать адреса многоадресной рассылки.

• GAA_FLAG_SKIP_DNS_SERVER — не возвращать адреса DNS-серверов.

• GAA_FLAG_INCLUDE_PREFIX — вернуть список префиксов IP-адресов для этого адаптера. Если этот флаг установлен, для адресов IPv6 и IPv4 возвращаются префиксы IP-адресов.

• GAA_FLAG_SKIP_FRIENDLY_NAME — не возвращать имя адаптера.

Следующие флаги поддерживаются только версиями, начиная с Windows Vista:

• GAA_FLAG_INCLUDE_WINS_INFO — вернуть адреса серверов WINS.

• GAA_FLAG_INCLUDE_GATEWAYS — вернуть адреса шлюзов по умолчанию.

• GAA_FLAG_INCLUDE_ALL_INTERFACES — вернуть адреса для всех интерфейсов NDIS.

• GAA_FLAG_INCLUDE_TUNNEL_BINDINGORDER — вернуть адреса, отсортированные по порядку привязки туннеля.

Флаг GAA_FLAG_INCLUDE_ALL_COMPARTMENTS зарезервирован и пока не поддерживается. Он задает возврат обратных адресов во всех секциях маршрутизации.

Структура IP_ADAPTER_ADRESSES — список, который содержит множество данных: тип интерфейса, его индекс, адреса, метрики и т.п. Она достаточно объемная и подробно описана в MSDN146.

Посмотрим на примере, как перечислять адаптеры. Сначала вызовем функцию с некоторым буфером, который, возможно, будет мал для размещения всех структур:

int main()

{

// Буфер для информации об одном адаптере.

std::vector<IP_ADAPTER_ADDRESSES> adapters(1);

ULONG out_size = adapters.size() * sizeof(IP_ADAPTER_ADDRESSES);

// Первый вызов: если повезет, данные будут получены.

if (const auto ret_code = GetAdaptersAddresses(AF_UNSPEC, 0, nullptr,

adapters.data(), &out_size);

ret_code != NO_ERROR)

{

Если буфера не хватило, функция вернет специальный код ERROR_BUFFER_OVERFLOW. В этом случае увеличим буфер и повторим вызов:

if (ERROR_BUFFER_OVERFLOW == ret_code)

{

// Увеличить размер буфера.

std::cout

<< "Increasing buffer [" << adapters.size() << " -> "

<< out_size / sizeof(IP_ADAPTER_ADDRESSES) + 1 << "]"

<< std::endl;

adapters.resize(out_size / sizeof(IP_ADAPTER_ADDRESSES) + 1);

out_size = adapters.size() * sizeof(IP_ADAPTER_ADDRESSES);

// И повторить вызов.

if (GetAdaptersAddresses(AF_UNSPEC, 0, nullptr,

adapters.data(), &out_size) != NO_ERROR)

{

std::cerr << "Can't get adapters addresses!" << std::endl;

return EXIT_FAILURE;

}

}

else

{

std::cerr << "Can't get adapters addresses!" << std::endl;

return EXIT_FAILURE;

}

}

Если вызов завершился успешно, остается только выполнить цикл по списку адаптеров, в котором выводятся их параметры:

// Инициализировать сеть, чтобы выполнить

// конвертацию адреса в строку функцией WSAAddressToString().

socket_wrapper::SocketWrapper sw;

// Цикл вывода параметров адаптеров.

for (auto adapter = adapters.data(); adapter; adapter = adapter->Next)

{

// Имена в структуре состоят из широких символов, выводим их

// в поток wcout.

std::wcout << "Adapter name: " << adapter->FriendlyName

<< " (" << adapter->AdapterName << ")\n"

<< "Description: " << adapter->Description << "\n"

<< "IPv4 interface index: " << adapter->IfIndex << "\n"

<< "IPv6 interface index: " << adapter->Ipv6IfIndex << "\n"

<< "MTU: " << adapter->Mtu << "\n"

<< "DNS Suffix: " << adapter->DnsSuffix << "\n"

<< "Have IPv4: " << adapter->Ipv4Enabled << "\n"

<< "Have IPv6: " << adapter->Ipv6Enabled << "\n"

<< "Adapter type: ";

// Тип адаптера.

switch (adapter->IfType)

{

case IF_TYPE_ETHERNET_CSMACD:

std::cout << "Ethernet device\n"; break;

case IF_TYPE_PPP:

std::cout << "PPP network interface\n"; break;

case IF_TYPE_SOFTWARE_LOOPBACK:

std::cout << "Software loopback\n"; break;

case IF_TYPE_REGULAR_1822:

std::cout << "1822\n"; break;

case IF_TYPE_IEEE80211:

std::cout << "Wireless IEEE 802.11\n"; break;

default: std::cout << "Other\n";

}

Выведем оперативный статус адаптера:

std::cout << "Operational status: ";

// Перечисление в строку.

switch (adapter->OperStatus)

{

case IfOperStatusUp: std::cout << "IfOperStatusUp\n"; break;

case IfOperStatusDown: std::cout << "IfOperStatusDown\n"; break;

case IfOperStatusTesting:

std::cout << "IfOperStatusTesting\n"; break;

case IfOperStatusUnknown:

std::cout << "IfOperStatusUnknown\n"; break;

case IfOperStatusDormant:

std::cout << "IfOperStatusDormant\n"; break;

case IfOperStatusNotPresent:

std::cout << "IfOperStatusNotPresent\n"; break;

case IfOperStatusLowerLayerDown:

std::cout << "IfOperStatusLowerLayerDown\n"; break;

default: std::cout << "Other\n";

};

Адаптеры могут иметь указатель на связанные IP-адреса. Это тоже список. Выведем его в отдельном цикле:

for (auto addr = adapter->FirstUnicastAddress; addr;

addr = addr->Next)

{

std::string ip(INET_ADDRSTRLEN, 0);

DWORD sz = ip.size();

// Конвертируем структуру наиболее подходящей функцией.

if (!WSAAddressToString(addr->Address.lpSockaddr,

addr->Address.iSockaddrLength, nullptr,

ip.data(), &sz))

std::cout << "My address = " << ip.c_str() << std::endl;

}

std::cout << std::endl;

}

return EXIT_FAILURE;

}

Для преобразования адреса используем ранее описанную функцию WSAAddressToString(). Именно она требует инициализации сетевой подсистемы вследствие использования вызова к сокетному провайдеру.

Запустим пример:

D:\build\bin> b01-ch18-get-adapters.exe

Increasing buffer [1 -> 37]

Adapter name: Ethernet ({6B875C37-F4FA-458A-A745-E04E64BE49BF})

Description: Realtek PCIe GBE Family Controller

IPv4 interface index: 6

IPv6 interface index: 0

MTU: 1500

DNS Suffix:

Have IPv4: 1

Have IPv6: 0

Adapter type: Ethernet device

Operational status: IfOperStatusUp

My address = 192.168.3.254

Adapter name: Loopback Pseudo-Interface 1 ({1B9974C7-CD33-11EB-A704-806E6F6E6963})

Description: Software Loopback Interface 1

IPv4 interface index: 1

IPv6 interface index: 1

MTU: 4294967295

DNS Suffix:

Have IPv4: 1

Have IPv6: 1

Adapter type: Software loopback

Operational status: IfOperStatusUp

My address = ::1

My address = 127.0.0.1

Видим, что в системе есть два адаптера. Первый — Ethernet-адаптер, который имеет только IPv4-адрес. Второй — интерфейс локальной петли с двумя адресами: IPv4 и IPv6.

Некоторые приложения используют адаптеры, которые принимают дейтаграммы, но не могут их передавать. Пример — зеркальные порты маршрутизаторов.

Для получения информации о таких адаптерах используется функция GetUniDirectionalAdapterInfo():

#include <ipexport.h>

#include <iphlpapi.h>

typedef ULONG IPAddr;

typedef struct _IP_UNIDIRECTIONAL_ADAPTER_ADDRESS

{

// Количество IPv4-адресов в Address.

ULONG  NumAdapters;

// Массив адресов.

IPAddr Address[1];

} IP_UNIDIRECTIONAL_ADAPTER_ADDRESS, *PIP_UNIDIRECTIONAL_ADAPTER_ADDRESS;

DWORD GetUniDirectionalAdapterInfo(PIP_UNIDIRECTIONAL_ADAPTER_ADDRESS

pIPIfInfo, PULONG dwOutBufLen);

Параметры функции GetUniDirectionalAdapterInfo():

• pIPIfInfo — указатель на буфер, в который будут записаны данные об однонаправленных адаптерах.

• dwOutBufLen — размер структуры, на которую указывает параметр pIPIfInfo.

При неудачном завершении функции возвращают код ошибки, иначе — NO_ERROR или ERROR_SUCCESS.

Управление сетевыми интерфейсами

В главе 10 мы уже говорили о том, что интерфейс — это абстракция уровня IP, а адаптер — абстракция уровня канала передачи данных. И хотя мы не всегда четко разграничивали интерфейсы и адаптеры, так как во многих случаях они соотносятся один к одному и эти термины можно считать взаимозаменяемыми, для функций управления интерфейсами это различие имеет значение.

Получение интерфейсов и их свойств

Таблицу интерфейсов можно получить, используя функцию GetIfTable(), которая возвращает все логические и физические интерфейсы в локальной системе.

#include <netioapi.h>

DWORD GetIfTable(PMIB_IFTABLE pIfTable, PULONG pdwSize, bool bOrder);

Параметры функции GetIfTable():

• pIfTable — указатель на буфер, получающий таблицу интерфейсов.

• pdwSize — указатель на размер буфера в байтах. Функция изменит размер, если он недостаточен.

• bOrder — если этот параметр принимает истинное значение, таблица будет отсортирована в порядке возрастания индексов интерфейсов.

Таблица интерфейсов представлена структурой, включающей массив и количество элементов в нем:

#include <ifmib.h>

typedef struct _MIB_IFTABLE

{

// Количество элементов таблицы.

DWORD dwNumEntries;

// Массив элементов.

MIB_IFROW table[ANY_SIZE];

} MIB_IFTABLE, *PMIB_IFTABLE;

Ряд таблицы:

#include <ifmib.h>

typedef struct _MIB_IFROW

{

// Название интерфейса.

WCHAR wszName[MAX_INTERFACE_NAME_LEN];

// Индекс интерфейса.

IF_INDEX dwIndex;

// IANA-тип интерфейса.

IFTYPE dwType;

// MTU интерфейса.

DWORD dwMtu;

// Скорость интерфейса в битах в секунду

DWORD dwSpeed;

// Длина физического адреса в байтах.

DWORD dwPhysAddrLen;

// Физический адрес.

UCHAR bPhysAddr[MAXLEN_PHYSADDR];

// Административное состояние интерфейса.

DWORD dwAdminStatus;

// Рабочее состояние интерфейса.

INTERNAL_IF_OPER_STATUS dwOperStatus;

В ОС Windows различаются поля dwAdminStatus — административное состояние — и dwOperStatus — оперативное состояние. Подробно разница между ними была описана в главе 10.

Административное состояние, которое задано администратором:

• NET_IF_ADMIN_STATUS_UP — интерфейс инициализируется и включается.

• NET_IF_ADMIN_STATUS_DOWN — интерфейс не работает. Его нельзя использовать для передачи или получения сетевых данных.

• NET_IF_ADMIN_STATUS_TESTING — интерфейс находится в тестовом режиме. Никакие сетевые данные не могут быть переданы или получены.

Эти константы определены в файле netioapi.h, а используются в структурах, описанных далее.

Чтобы указать реальное состояние интерфейса, используется поле dwOperStatus оперативного состояния, которое может принимать следующие значения:

• IF_OPER_STATUS_NON_OPERATIONAL — интерфейс локальной сети отключен, например, из-за конфликта адресов.

• IF_OPER_STATUS_UNREACHABLE — неподключенный интерфейс глобальной сети.

• IF_OPER_STATUS_DISCONNECTED — для интерфейса локальной сети: сетевой кабель не подключен. Для интерфейса глобальной сети: нет оператора.

• IF_OPER_STATUS_CONNECTING — интерфейс глобальной сети, находящийся в процессе подключения.

• IF_OPER_STATUS_CONNECTED — интерфейс глобальной сети, подключенный к узлу одноранговой сети.

• IF_OPER_STATUS_OPERATIONAL — состояние по умолчанию для адаптеров локальной сети.

Другие поля структуры содержат различную статистику:

// Количество сотых секунд, в течение которых работает интерфейс,

// начиная с последней загрузки системы. Не поддерживается.

DWORD dwLastChange;

// Количество октетов, полученных интерфейсом.

DWORD dwInOctets;

// Количество одноадресных пакетов, прошедших через интерфейс.

DWORD dwInUcastPkts;

// Количество неодноадресных пакетов, полученных интерфейсом.

DWORD dwInNUcastPkts;

// Количество отброшенных пакетов.

DWORD dwInDiscards;

// Количество входящих пакетов с ошибками.

DWORD dwInErrors;

// Количество входящих пакетов, отброшенных из-за неизвестного протокола.

DWORD dwInUnknownProtos;

// Количество октетов, отправленных через интерфейс.

DWORD dwOutOctets;

// Количество одноадресных пакетов, отправленных через интерфейс.

DWORD dwOutUcastPkts;

// Количество неодноадресных пакетов, отправленных через интерфейс.

DWORD dwOutNUcastPkts;

// Количество отброшенных исходящих пакетов.

DWORD dwOutDiscards;

// Количество исходящих пакетов отброшенных из-за ошибок.

DWORD dwOutErrors;

// Длина очереди передачи. Сейчас не используется.

DWORD dwOutQLen;

// Длина поля описания интерфейса.

DWORD dwDescrLen;

// Поле описания интерфейса.

UCHAR bDescr[MAXLEN_IFDESCR];

} MIB_IFROW, *PMIB_IFROW;

Чтобы получить данные по конкретному интерфейсу, в структуре MIB_IFROW необходимо заполнить поле dwIndex, а затем передать указатель на структуру функции GetIfEntry():

DWORD GetIfEntry(PMIB_IFROW pIfRow);

Если функция завершится успешно, она вернет значение NO_ERROR.

Начиная с версии Windows Vista, для работы с интерфейсами были добавлены новые функции:

• GetIfTable2().

• GetIfTable2Ex() — расширенная версия функции GetIfTable2(), которая позволяет выбрать уровень извлекаемой информации интерфейса.

#include <netioapi.h>

NETIO_STATUS GetIfTable2(PMIB_IF_TABLE2 *table);

NETIO_STATUS GetIfTable2Ex(MIB_IF_TABLE_LEVEL level, PMIB_IF_TABLE2 *table);

Параметры функций GetIfTable2() и GetIfTable2Ex():

• table — указатель на буфер, получающий таблицу интерфейсов.

• level — уровень извлекаемых сведений об интерфейсе:

• MibIfTableNormal — возвращается информация из верхней части стека фильтров.

• MibIfTableRaw — возвращается информация с уровня интерфейса.

Параметр level требуется для того, чтобы узнавать некоторые статистические данные об адаптере, минуя фильтры. Например, фильтр может изменить состояние активности интерфейса с up на down, и если требуется получить информацию непосредственно с устройства, необходимо использовать значение MibIfTableRaw.

В отличие от функции GetIfTable(), для которой буфер должен быть предварительно выделен, эти функции сами выделяют буфер, который затем требуется освободить, вызвав функцию:

void FreeMibTable(PVOID Memory);

Внимание! Хотя оба варианта функций, как с суффиксом 2, так и без него, выполняют сходные действия, они работают с абсолютно разными структурами, которые между собой несовместимы.

Таблицы, возвращаемые функциями, содержат более подробную, но похожую информацию:

#include <netioapi.h>

typedef struct _MIB_IF_TABLE2

{

// Количество элементов в массиве.

ULONG NumEntries;

// Массив структур.

MIB_IF_ROW2 Table[ANY_SIZE];

} MIB_IF_TABLE2, *PMIB_IF_TABLE2;

Приведем здесь наиболее значимую часть ряда таблицы, а оставшуюся читатель сможет найти в MSDN147:

typedef struct _MIB_IF_ROW2

{

// Локальный уникальный идентификатор интерфейса.

NET_LUID InterfaceLuid;

// Индекс интерфейса.

NET_IFINDEX InterfaceIndex;

// Глобальный уникальный идентификатор.

GUID InterfaceGuid;

// Псевдоним интерфейса.

WCHAR Alias[IF_MAX_STRING_SIZE + 1];

// Описание интерфейса.

WCHAR Description[IF_MAX_STRING_SIZE + 1];

// Длина MAC-адреса.

ULONG PhysicalAddressLength;

// MAC-адрес.

UCHAR PhysicalAddress[IF_MAX_PHYS_ADDRESS_LENGTH];

// Постоянный MAC-адрес.

UCHAR PermanentPhysicalAddress[IF_MAX_PHYS_ADDRESS_LENGTH];

// MTU интерфейса.

ULONG Mtu;

// IANA-тип интерфейса.

IFTYPE Type;

// Метод инкапсуляции, который используется туннелем.

// TUNNEL_TYPE_NONE — не туннель, TUNNEL_TYPE_DIRECT — инкапсуляция в IP.

// TUNNEL_TYPE_6TO4 — IPv6 в IPv4 и т.п.

TUNNEL_TYPE TunnelType;

// Тип носителя NDIS.

NDIS_MEDIUM MediaType;

// Тип физической среды.

NDIS_PHYSICAL_MEDIUM PhysicalMediumType;

// Тип доступа интерфейса.

NET_IF_ACCESS_TYPE AccessType;

// Направление обмена данными.

NET_IF_DIRECTION_TYPE DirectionType;

...

// Далее идут флаги, поля состояния и статистики интерфейса.

} MIB_IF_ROW2, *PMIB_IF_ROW2;

MAC-адрес можно изменить программно, и поэтому в структуре возвращается два поля: текущий MAC-адрес и постоянный.

Тип носителя — это тип нижележащей физической сети. Например, NdisMedium802_3 — это сеть Ethernet, NdisMedium802_5 — Token Ring и т.п. Все типы перечислены в MSDN.

Тип физической среды — это коммуникационная среда, которая передает данные непосредственно:

• NdisPhysicalMediumUnspecified — не указана. Например, односторонние спутниковые каналы являются таковыми.

• NdisPhysicalMediumWirelessLan — беспроводная сеть 802.11.

• NdisPhysicalMediumCableModem — кабельный модем DOCSIS.

• NdisPhysicalMediumPhoneLine — телефонные линии PSTN.

• NdisPhysicalMediumNative802_11 — сеть 802.11.

Всего определено порядка 20 типов среды передачи, которые также подробно описаны в MSDN.

Типы доступа:

• NET_IF_ACCESS_LOOPBACK — локальная петля. Интерфейс получает то, что отправил.

• NET_IF_ACCESS_BROADCAST — широковещательный доступ. Интерфейс обеспечивает встроенную поддержку служб многоадресной или широковещательной рассылки. Например, Ethernet.

• NET_IF_ACCESS_POINT_TO_POINT — «точка-точка».

• NET_IF_ACCESS_POINT_TO_MULTI_POINT — «точка-многоточка». Носители с несколькими доступами или non-broadcast multiple access network, NBMA. Например, внутренний интерфейс RAS или X.25.

• NET_IF_ACCESS_MAXIMUM — максимально возможное значение для перечисления. Не является допустимым.

Тип направления обмена данными:

• NET_IF_DIRECTION_SENDRECEIVE — двунаправленный интерфейс.

• NET_IF_DIRECTION_SENDONLY — интерфейс, который может только отправлять данные.

• NET_IF_DIRECTION_RECEIVEONLY — интерфейс, который может только принимать данные.

• NET_IF_DIRECTION_MAXIMUM — максимально возможное значение типа. Не является допустимым.

Рабочее состояние интерфейса также содержится в таблице, но здесь не показано. Его тип отличается от типа в MIB_IFROW. Остальные поля во многом похожи.

Внимание! После завершения работы с таблицей ее нужно удалить, вызвав функцию FreeMibTable().

В новом API существуют аналогичные функции для получения данных конкретного интерфейса:

NETIO_STATUS GetIfEntry2(PMIB_IF_ROW2 Row);

NETIO_STATUS GetIfEntry2Ex(MIB_IF_ENTRY_LEVEL Level, PMIB_IF_ROW2 Row);

Чтобы ими воспользоваться, необходимо заполнить в передаваемой структуре атрибуты InterfaceLuid или InterfaceIndex.

Если InterfaceLuid и InterfaceIndex не соответствуют друг другу и указывают на разные адаптеры, будет использоваться InterfaceLuid.

Получить количество интерфейсов позволяет функция GetNumberOfInterfaces():

#include <iphlpapi.h>

DWORD GetNumberOfInterfaces(PDWORD pdwNumIf);

Результат будет записан по указателю pdwNumIf. Ее вызов может быть полезен для предварительного выделения буфера при вызове функции GetIfEntry().

Функция GetInterfaceInfo() возвращает таблицу, содержащую имена и соответствующие индексы для интерфейсов:

#include <ipexport.h>

#include <iphlpapi.h>

typedef struct _IP_INTERFACE_INFO

{

// Количество элементов массива.

LONG NumAdapters;

// Массив отображений

IP_ADAPTER_INDEX_MAP Adapter[1];

} IP_INTERFACE_INFO, *PIP_INTERFACE_INFO;

typedef struct _IP_ADAPTER_INDEX_MAP

{

// Индекс интерфейса, связанный с адаптером.

ULONG Index;

// Имя адаптера либо его GUID.

WCHAR Name[MAX_ADAPTER_NAME];

} IP_ADAPTER_INDEX_MAP, *PIP_ADAPTER_INDEX_MAP;

DWORD GetInterfaceInfo(PIP_INTERFACE_INFO pIfTable, PULONG dwOutBufLen);

Параметры функции GetInterfaceInfo():

• pIfTable — указатель на буфер;

• dwOutBufLen — размер буфера, на который указывает параметр pIfTable.

При успешном вызове функция вернет NO_ERROR.

Изменение конфигурации

С помощью функции SetIfEntry() можно изменить административный статус интерфейса — включить его или выключить:

#include <iphlpapi.h>

DWORD SetIfEntry(PMIB_IFROW pIfRow);

В передаваемой структуре следует задать индекс определенного интерфейса в атрибуте dwIndex и необходимое значение атрибута dwAdminStatus.

Вызов, введенный начиная с Windows Vista, позволяет задать свойства интерфейса:

#include <netioapi.h>

NETIO_STATUS SetIpNetEntry2(PMIB_IPNET_ROW2 Row);

Чтобы указать адаптер, следует установить атрибут InterfaceLuid или InterfaceIndex. Также должны быть корректно установлены атрибуты PermanentAddress, PhysicalAddress и PhysicalAddressLength.

Предполагается, что до того, как применять одну из Set-функций, разработчик инициализировал структуру, предварительно вызвав соответствующую Get-функцию.

Управлять интерфейсами могут только администраторы.

Взаимное преобразование идентификаторов интерфейса

Основные идентификаторы, которыми адресуются интерфейсы в ОС Windows, — LUID и GUID.

LUID — это локальный уникальный идентификатор (Locally Unique IDentifier):

#include <ifdef.h>

typedef union _NET_LUID_LH

{

// Идентификатор.

ULONG64 Value;

struct

{

ULONG64 Reserved : 24;

// Индекс LUID.

ULONG64 NetLuidIndex : 24;

// IANA-тип интерфейса.

ULONG64 IfType : 16;

} Info;

} NET_LUID_LH, *PNET_LUID_LH;

typedef NET_LUID_LH NET_LUID;

typedef NET_LUID* PNET_LUID;

// Тип идентификатора локального интерфейса -

// Locally unique datalink interface.

typedef NET_LUID IF_LUID, *PIF_LUID;

Второй тип идентификатора — GUID (Globally Unique IDentifier). Это число, которое является глобальным идентификатором, не имеющим смысловой нагрузки.

Список индексов LUID хранится в ключах реестра:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\NDIS\IfTypes[тип интерфейса]\IfUsedNetLuidIndices.

Выделить же интерфейс можно через вызов функции NDIS NdisIfAllocateNetLuidIndex().

По крайней мере, в рамках одной системы GUID однозначно преобразуется в LUID и обратно с помощью соответствующих функций:

#include <netioapi.h>

// GUID в LUID.

NETIO_STATUS ConvertInterfaceGuidToLuid(

const GUID *InterfaceGuid,

PNET_LUID  InterfaceLuid

);

// LUID в GUID.

NETIO_STATUS ConvertInterfaceLuidToGuid(

const NET_LUID *InterfaceLuid,

GUID *InterfaceGuid

);

Индекс также уникален для интерфейса в системе, и он может быть однозначно преобразован в LUID:

typedef ULONG NET_IFINDEX, *PNET_IFINDEX;

// Индекс в LUID.

NETIO_STATUS ConvertInterfaceIndexToLuid(

NET_IFINDEX InterfaceIndex,

PNET_LUID InterfaceLuid

);

// LUID в индекс.

NETIO_STATUS ConvertInterfaceLuidToIndex(

const NET_LUID *InterfaceLuid,

PNET_IFINDEX InterfaceIndex

);

Уникальное имя в системе некоторого интерфейса может быть сконвертировано в индекс с помощью следующих функций:

NETIO_STATUS if_indextoname(NET_IFINDEX InterfaceIndex, PCHAR InterfaceName);

NETIO_STATUS if_nametoindex(PCSTR InterfaceName);

Более современный вариант — использовать преобразование имени в LUID и обратно:

// LUID в имя.

NETIO_STATUS ConvertInterfaceLuidToNameW(

const NET_LUID *InterfaceLuid,

PSTR InterfaceName,

size_t Length

);

// Имя в LUID.

NETIO_STATUS ConvertInterfaceNameToLuidW(

const WCHAR *InterfaceName,

NET_LUID *InterfaceLuid

);

Указанные функции работают только с широкими строками.

Псевдонимы интерфейсов также могут быть преобразованы в LUID и обратно:

#include <netioapi.h>

NETIO_STATUS ConvertInterfaceAliasToLuid(

const WCHAR *InterfaceAlias,

PNET_LUID InterfaceLuid

);

NETIO_STATUS ConvertInterfaceLuidToAlias(

const NET_LUID *InterfaceLuid,

PWSTR InterfaceAlias,

size_t Length

);

Как пример, в реализованной на C части модуля socket в CPython метод socket.if_nameindex() использует данные функции.

Сначала вызывается GetIfTable2Ex():

#ifdef MS_WINDOWS

// Указатель на таблицу интерфейсов.

PMIB_IF_TABLE2 tbl;

// Код возврата функции.

int ret;

// Получить таблицу интерфейсов.

if ((ret = GetIfTable2Ex(MibIfTableRaw, &tbl)) != NO_ERROR)

{

// Управление ссылками в Python, далее эти места будут пропущены.

Py_DECREF(list);

// Вместо GetLastError () используется ret.

return PyErr_SetFromWindowsErr(ret);

}

Затем в цикле производится конвертация идентификаторов интерфейсов в имена:

for (ULONG i = 0; i < tbl->NumEntries; ++i)

{

MIB_IF_ROW2 r = tbl->Table[i];

// Буфер для имени.

WCHAR buf[NDIS_IF_MAX_STRING_SIZE + 1];

// Сконвертировать LUID в имя, которое будет записано в buf.

if ((ret = ConvertInterfaceLuidToNameW(&r.InterfaceLuid, buf,

Py_ARRAY_LENGTH(buf))))

{

// Помним, что необходимо освобождать память.

FreeMibTable(tbl);

// Вместо GetLastError() используется код возврата.

return PyErr_SetFromWindowsErr(ret);

}

// Построить кортеж, содержащий индекс и имя, затем добавим в список.

PyObject *tuple = Py_BuildValue("Iu", r.InterfaceIndex, buf);

if (tuple == nullptr || PyList_Append(list, tuple) == -1)

{

FreeMibTable(tbl);

return nullptr;

}

}

// Очистить память, выделенную под таблицу.

FreeMibTable(tbl);

return list;

Затем таблица освобождается, а в Python возвращаются полученные данные интерфейсов.

Резюме

Хотя в ОС Windows, так же как в Linux, после создания сокета можно управлять различными его атрибутами с помощью функций getsockopt(), setsockopt(), номенклатура опций в этих системах значительно отличается.

Кроме того, в ОС Windows функции ioctl() и fcntl() отсутствуют. Их заменяют функции ioctlsocket(), WSAIoctl() и несколько функций для управления дескрипторами. Поскольку Netlink-подобного интерфейса в ОС Windows тоже нет, ioctl существует очень много, а их состав расширяется с выходом каждой новой версии системы.

Функция WSAIoctl() может работать в overlapped-режиме, чтобы предотвращать длительное ожидание завершения некоторых вызовов.

Вызовы существуют для управления уведомлениями, интерфейсами, буферами, для привязки обработки данных сокета к определенным ядрам процессора, резервирования портов, настроек TCP и UDP, настройки WFP и т.д.

Для удобства работы с вызовами и опциями в API существуют функции-обертки. Большая часть из них объявлена в заголовочном файле ws2tcpip.h.

Работать со вспомогательными данными в WinSock можно, используя функции WSASendMsg() и WSARecvMsg(), которые относятся к расширениям от Microsoft.

Функция DeviceIoControl() позволяет совершать вызовы к драйверам устройств. С ее помощью можно управлять сетевыми адаптерами и интерфейсами и корректировать работу сетевых интерфейсов, позволяя оптимизировать процессы передачи данных в зависимости от задач приложения.

Чтобы разрабатывать надежное и эффективное сетевое программное обеспечение, важно уметь правильно использовать инструментарий для настройки сетевых интерфейсов, сокетов и устройств.

Вопросы и задания

1. Зачем нужны функции-обертки над опциями?

2. В каких случаях следует использовать функции-обертки для установки опций, а в каких — нет?

3. Как получить время, в течение которого используется подключенный сокет?

4. Когда стоит использовать опцию сокета SO_OPENTYPE? Почему?

5. Какую задачу выполняет опция SO_REUSE_UNICASTPORT?

6. Что будет, если опция SO_UPDATE_ACCEPT_CONTEXT на сокете выключена?

7. Можно ли ограничить список интерфейсов, к которым производится подключение? Если да, то как? Для чего это может быть нужно?

8. Как включить получение явных уведомлений о перегрузке?

9. Каким образом можно ограничить диапазон IPv6-адресов, с которыми может обмениваться сокет? Для чего это может быть нужно?

10. Как включить объединение UDP-сообщений в одну дейтаграмму? Для чего это может потребоваться?

11. Какие функции используются для работы со вспомогательными данными в WinSock?

12. Для чего используются функции ioctlsocket() и WSAIoctl()? В чем между ними разница?

13. Чем отличается в ОС Windows функция ioctlsocket() от своего аналога ioctl() в Linux?

14. Как можно избежать блокировки выполнения кода при использовании функций ioctl в Windows?

15. Как получить список сетевых интерфейсов в WinSock, используя вызов ioctl?

16. Какие ioctl позволяют определить идеальный размер буфера отправки? Как ими пользоваться?

17. Как ограничить диапазон портов для приложения?

18. Каким образом включить для сокета неразборчивый режим и какие для этого должны быть соблюдены условия?

19. Как включить для сокета обязательное шифрование трафика?

20. Какие функции используются для управления дескрипторами сокетов?

21. Каким образом возможно переключить наследование дочерним процессом дескриптора конкретного сокета? Для чего это может быть полезно?

22. Как в ОС Windows вызвать функцию драйвера сетевого устройства? Для чего это может потребоваться?

23. Как в ОС Windows узнать, что сетевой интерфейс работает?

24. Какая функция изменяет административное состояние интерфейса?

25. Чем отличается функция для работы с интерфейсами GetIfTable2Ex() от функции GetIfTable2()?

26. В начале главы показан пример установки MTU с помощью функции-обертки. Создайте свой пример, в котором MTU устанавливается без использования функций-оберток.

27. Расширьте пример, выводящий все сетевые адаптеры в системе. Отключите сетевые адаптеры, у которых отсутствует подключение к сети.

141 https://docs.microsoft.com/en-us/windows/win32/api/mswsock/nf-mswsock-acceptex

142 https://docs.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse

143 https://learn.microsoft.com/en-us/windows/win32/fwp/application-layer-enforcement--ale-

144 https://learn.microsoft.com/en-us/windows/win32/ad/service-principal-names

145 https://techcommunity.microsoft.com/t5/networking-blog/ledbat-background-data-transfer-for-windows/ba-p/3639278

146 https://learn.microsoft.com/ru-ru/windows/win32/api/iptypes/ns-iptypes-ip_adapter_addresses_lh

147 https://learn.microsoft.com/ru-ru/windows/win32/api/netioapi/ns-netioapi-mib_if_row2

Глава 19. IP Helper API

Было бы неплохо возвращать деньги за дефектное ПО, вот только это за год обанкротит всю индустрию разработки.

Эндрю С. Таненбаум, «Компьютерные сети»

Введение

В этой главе мы рассмотрим вспомогательную библиотеку IP Helper, предоставляющую API для работы с параметрами конфигурации протоколов и сетевых устройств.

Часть API IP Helper уже была рассмотрена в прошлых главах. В этой главе описываются структуры данных и API для получения статистики, уведомлений об изменении конфигурации сети, работы с DHCP и т.п.

Далее мы коснемся диагностического API, в том числе для работы с ICMP, и на примере утилиты ping разберемся, как его использовать.

Закончим главу описанием функций, предоставляющих информацию о состоянии протоколов TCP/IP, а также об интерфейсах, маршрутах и статистике, которые применяются чаще всего и которые мы еще не описывали.

В случае успеха все функции IP Helper возвращают значение NO_ERROR, если не было оговорено иное.

Данную главу можно использовать в качестве справочника при дальнейшей работе с книгой.

Получение информации о конфигурации сети

Для получения общей информации о конфигурации сети можно использовать функцию GetNetworkParams(). Эта функция возвращает, например, список DNS-серверов, используемых локальным компьютером:

#include <iphlpapi.h>

DWORD GetNetworkParams(PFIXED_INFO pFixedInfo, PULONG pOutBufLen);

Параметры функции GetNetworkParams():

• pFixedInfo — указатель на структуру FIXED_INFO, которая получает сетевые параметры.

• pOutBufLen — указатель на размер структуры, в который будет записан необходимый размер буфера, если переданного размера недостаточно.

Структура FIXED_INFO содержит список IP-структур IP_ADDR_STRING:

#include <iphlpapi.h>

#include <iptypes.h>

// Элемент списка адресов.

typedef struct _IP_ADDR_STRING

{

// Указатель на следующий элемент.

struct _IP_ADDR_STRING *Next;

// Строка IP-адреса в десятичной нотации.

IP_ADDRESS_STRING IpAddress;

// Строка маски.

IP_MASK_STRING IpMask;

// Запись сетевой таблицы, используемая функциями AddIPAddress()

// и DeleteIPAddress().

DWORD Context;

} IP_ADDR_STRING, *PIP_ADDR_STRING;

Этот список содержится в передаваемой функции структуре FIXED_INFO:

typedef struct

{

// Имя локального узла.

char HostName[MAX_HOSTNAME_LEN + 4];

// Имя домена локального узла.

char DomainName[MAX_DOMAIN_NAME_LEN + 4];

// Зарезервировано. Вместо нее используется следующее поле.

PIP_ADDR_STRING CurrentDnsServer;

// Список адресов DNS-серверов.

IP_ADDR_STRING DnsServerList;

// Тип узла.

UINT NodeType;

// Область DHCP.

char ScopeId[MAX_SCOPE_ID_LEN + 4];

// Включена ли маршрутизация?

UINT EnableRouting;

// Этот узел — прокси?

UINT EnableProxy;

// Включена ли служба DNS на локальном узле.

UINT EnableDns;

} FIXED_INFO_W2KSP1, *PFIXED_INFO_W2KSP1;

// В новых версиях ОС структура FIXED_INFO аналогична FIXED_INFO_W2KSP1.

#if (NTDDI_VERSION >= NTDDI_WIN2KSP1)

typedef FIXED_INFO_W2KSP1 FIXED_INFO;

typedef FIXED_INFO_W2KSP1 *PFIXED_INFO;

#endif

Тип узла NetBIOS в NodeType влияет на тип разрешения имен и может иметь следующие значения:

• BROADCAST_NODETYPE — широковещательный узел. Пытается разрешить имена путем широковещательной рассылки.

• PEER_TO_PEER_NODETYPE — одноранговый узел.

• MIXED_NODETYPE — смешанный тип. Сначала использует широковещание, затем WINS.

• HYBRID_NODETYPE — гибридный узел. Сначала использует WINS, затем широковещание.

Большую часть параметров узла, которые возвращает функция GetNetworkParams(), можно получить, вызвав из командной строки команду ipconfig /all.

В Windows 10 были добавлены функции для получения состояния и способа тарификации подключения к сети, а также определения наличия доступа в интернет. Эти функции опираются на структуру NL_NETWORK_CONNECTIVITY_HINT:

#include <nldef.h>

typedef struct _NL_NETWORK_CONNECTIVITY_HINT

{

// Уровень сетевого подключения.

NL_NETWORK_CONNECTIVITY_LEVEL_HINT ConnectivityLevel;

// Стоимость подключения, затраты на его использование.

NL_NETWORK_CONNECTIVITY_COST_HINT ConnectivityCost;

// Истина, если подключение исчерпывает лимит данных.

BOOLEAN ApproachingDataLimit;

// Истина, если подключение уже превысило лимит данных.

BOOLEAN OverDataLimit;

// Подключение в роуминге.

BOOLEAN Roaming;

} NL_NETWORK_CONNECTIVITY_HINT;

В структуре используются следующие перечисления уровней подключения:

#include <nldef.h>

// Определяет константы, указывающие на уровень сетевого подключения.

typedef enum _NL_NETWORK_CONNECTIVITY_LEVEL_HINT

{

// Неизвестный уровень подключения.

NetworkConnectivityLevelHintUnknown = 0,

// Подключение отсутствует.

NetworkConnectivityLevelHintNone,

// Имеется подключение только к локальной сети.

NetworkConnectivityLevelHintLocalAccess,

// Имеется доступ в интернет.

NetworkConnectivityLevelHintInternetAccess,

// Ограниченное интернет-подключение.

NetworkConnectivityLevelHintConstrainedInternetAccess,

// Интерфейс скрыт для подключений.

NetworkConnectivityLevelHintHidden

} NL_NETWORK_CONNECTIVITY_LEVEL_HINT;

«Ограниченное подключение» — такое, при котором есть локальный доступ к веб-порталу, требующему авторизации, чтобы подключиться к интернету. Пример такого подключения — общественный Wi-Fi.

Внимание! Данный статус не гарантирует, что такой портал был обнаружен.

Перечисление NL_NETWORK_CONNECTIVITY_COST_HIN определяет константы, указывающие способ тарификации подключения к сети:

// Стоимость подключения.

typedef enum _NL_NETWORK_CONNECTIVITY_COST_HINT

{

// Сведения о расходах недоступны.

NetworkConnectivityCostHintUnknown = 0,

// Неограниченное подключение.

NetworkConnectivityCostHintUnrestricted,

// Некоторый объем трафика предоставлен бесплатно.

NetworkConnectivityCostHintFixed,

// Плата за трафик.

NetworkConnectivityCostHintVariable

} NL_NETWORK_CONNECTIVITY_COST_HINT;

Порядок работы с функциями для получения структуры описания подключений должен быть понятен:

#include <netioapi.h>

// Получить данные по всем интерфейсам.

NETIO_STATUS GetNetworkConnectivityHint(

NL_NETWORK_CONNECTIVITY_HINT *ConnectivityHint

);

// Получить данные по заданному интерфейсу.

NETIO_STATUS GetNetworkConnectivityHintForInterface(

NET_IFINDEX InterfaceIndex,

NL_NETWORK_CONNECTIVITY_HINT *ConnectivityHint

);

Можно вызвать функцию либо для получения общего состояния, либо для получения состояния конкретного адаптера по индексу.

Также существует асинхронная функция, выдающая оповещение при появлении информации о соединении:

// Тип функции обратного вызова.

typedef void (NETIOAPI_API_ *PNETWORK_CONNECTIVITY_HINT_CHANGE_CALLBACK)(

// Пользовательское значение.

void* CallerContext,

// Состояние подключения.

NL_NETWORK_CONNECTIVITY_HINT ConnectivityHint

);

// Функция получения уведомления о состоянии интерфейса.

NETIO_STATUS NotifyNetworkConnectivityHintChange(

PNETWORK_CONNECTIVITY_HINT_CHANGE_CALLBACK Callback,

void* CallerContext,

BOOLEAN InitialNotification,

PHANDLE NotificationHandle

);

Параметры функции NotifyNetworkConnectivityHintChange():

• Callback — функция обратного вызова, которая будет вызвана, когда информация о соединении будет доступна.

• CallerContext — значение, которое может задать пользователь для передачи в функцию обратного вызова.

• InitialNotification — истина, если должно быть предоставлено уведомление об инициализации.

• NotificationHandle — указатель на хэндл, который будет использован для уведомления.

Тип NETIO_STATUS, возвращаемый функцией, — DWORD.

Управление IP-адресами

IP Helper позволяет управлять IP-адресами, связанными с интерфейсами на локальном компьютере.

Получение данных об IP-адресах интерфейса

Функция GetIpAddrTable() возвращает таблицу, содержащую сопоставление IP-адресов с интерфейсами:

#include <iphlpapi.h>

DWORD GetIpAddrTable(PMIB_IPADDRTABLE pIpAddrTable, PULONG pdwSize,

bool bOrder);

Параметры функции GetIpAddrTable():

• pIpAddrTable — указатель на буфер, который получает таблицу сопоставления адресов интерфейса с адресами IPv4.

• pdwSize — размер буфера в байтах, на который указывает pIpAddrTable.

• bOrder — если истина, возвращаемая таблица будет отсортирована в порядке возрастания адресов.

С одним и тем же интерфейсом может быть связано более одного IP-адреса. Таблица описывается следующей структурой:

#include <ipmib.h>

typedef struct _MIB_IPADDRTABLE

{

// Количество элементов в таблице.

DWORD dwNumEntries;

// Таблица адресов.

MIB_IPADDRROW table[ANY_SIZE];

} MIB_IPADDRTABLE, *PMIB_IPADDRTABLE;

typedef struct _MIB_IPADDRROW_XP

{

// IPv4 в сетевом порядке байтов.

DWORD dwAddr;

// Индекс связанного с адресом интерфейса.

IF_INDEX dwIndex;

// Маска подсети в сетевом порядке байтов.

DWORD dwMask;

// Широковещательный адрес.

DWORD dwBCastAddr;

// Максимальный размер пересборки полученных дейтаграмм.

DWORD dwReasmSize;

// Не используется.

unsigned short unused1;

// Тип или состояние адреса.

unsigned short wType;

} MIB_IPADDRROW_XP, *PMIB_IPADDRROW_XP;

typedef MIB_IPADDRROW_XP MIB_IPADDRROW;

typedef MIB_IPADDRROW_XP *PMIB_IPADDRROW;

Атрибут wType может принимать следующие значения:

• MIB_IPADDR_PRIMARY — основной IP-адрес.

• MIB_IPADDR_DYNAMIC — динамический IP-адрес, например полученный по DHCP.

• MIB_IPADDR_DISCONNECTED — адрес на отключенном интерфейсе.

• MIB_IPADDR_DELETED — адрес в процессе удаления.

• MIB_IPADDR_TRANSIENT — переходный адрес, например адрес устройств, не имеющих постоянного подключения к сети. Это адрес, который выдается на фиксированное время.

Добавить IP-адреса к интерфейсу можно с помощью функции AddIPAddress():

#include <iphlpapi.h>

DWORD AddIPAddress(IPAddr Address, IPMask IpMask, DWORD IfIndex,

PULONG NTEContext, PULONG NTEInstance);

Параметры функции AddIPAddress():

• Address — IPv4-адрес, добавляемый адаптеру.

• IpMask — маска подсети.

• IfIndex — индекс адаптера, которому необходимо добавить IPv4-адрес.

• NTEContext — при успешном вызове этот параметр указывает на контекст для добавленного IPv4-адреса.

• NTEInstance — при успешном возвращении этот параметр указывает на экземпляр NTE для добавленного IPv4-адреса.

Чтобы удалить IP-адреса, ранее добавленные с помощью AddIPAddress(), используется функция DeleteIPAddress():

#include <iphlpapi.h>

DWORD DeleteIPAddress(ULONG NTEContext);

Она принимает контекст, возвращенный функцией DeleteIPAddress().

С версии Windows Vista для разных типов адресов существует отдельный набор API, которые работают с отдельными типами адресов и соответствующих им структур.

API для «обычных» unicast-адресов:

#include <netioapi.h>

void NETIOAPI_API_ InitializeUnicastIpAddressEntry(

PMIB_UNICASTIPADDRESS_ROW Row

);

NETIO_STATUS CreateUnicastIpAddressEntry(const MIB_UNICASTIPADDRESS_ROW *Row);

NETIO_STATUS GetUnicastIpAddressEntry(PMIB_UNICASTIPADDRESS_ROW Row);

NETIO_STATUS SetUnicastIpAddressEntry(const MIB_UNICASTIPADDRESS_ROW *Row);

NETIO_STATUS DeleteUnicastIpAddressEntry(const MIB_UNICASTIPADDRESS_ROW *Row);

Для anycast-адресов:

#include <netioapi.h>

NETIO_STATUS CreateAnycastIpAddressEntry(const MIB_ANYCASTIPADDRESS_ROW *Row);

NETIO_STATUS GetAnycastIpAddressEntry(PMIB_ANYCASTIPADDRESS_ROW Row);

NETIO_STATUS DeleteAnycastIpAddressEntry(const MIB_ANYCASTIPADDRESS_ROW *Row);

Для multicast-адресов:

#include <netioapi.h>

NETIO_STATUS GetMulticastIpAddressTable(

ADDRESS_FAMILY Family,

PMIB_MULTICASTIPADDRESS_TABLE *Table

);

NETIO_STATUS GetMulticastIpAddressEntry(PMIB_MULTICASTIPADDRESS_ROW Row);

В связи с тем, что объем книги ограничен, мы не будем их рассматривать подробно. Но для примера используем данные функции из Python через модуль ctypes:

import ctypes

# windll будет использован для импорта DLL.

from ctypes import windll

from ctypes.wintypes import DWORD, USHORT

import ipaddress

from socket import htonl

def GetIpAddrTable():

size = DWORD()

# Первый вызов должен вернуть размер.

windll.iphlpapi.GetIpAddrTable(None, ctypes.byref(size), 0)

# Структура, отражающая ряд таблицы.

class MIB_IPADDRROW(ctypes.Structure):

_fields_ = [

('dwAddr', DWORD),

('dwIndex', DWORD),

('dwMask', DWORD),

('dwBCastAddr', DWORD),

('dwReasmSize', DWORD),

('unused1', USHORT),

('wType', USHORT),

]

# Таблица адресов. Количество структур известно.

class MIB_IPADDRTABLE(ctypes.Structure):

_fields_ = [('dwNumEntries', DWORD),

('table', MIB_IPADDRROW * size.value)]

Модуль несложен в использовании: даже нет необходимости загружать библиотеку, потому что она уже предоставляется модулем.

Первый вызов был сделан «вхолостую», чтобы получить необходимый размер буфера. Затем мы сформировали требуемую для вызова структуру, используя типы WinAPI. Причем определенный в предыдущем вызове размер буфера сразу был заложен в класс.

Теперь создадим экземпляр таблицы и вызовем API-функцию повторно:

# Создать объект таблицы.

ip_table = MIB_IPADDRTABLE()

# Вызвать функцию WinAPI.

if (rc := windll.iphlpapi.GetIpAddrTable(ctypes.byref(ip_table),

ctypes.byref(size), 0)) != 0:

raise OSError(f'GetIpAddrTable returned {rc}')

return ip_table

addrs = GetIpAddrTable()

# Записей в таблице может быть больше, чем реальных адресов.

# Поэтому обход делаем только по элементам dwNumEntries.

for i in range(addrs.dwNumEntries):

# Адреса в хостовом порядке байтов. Их нужно преобразовать в сетевой.

print(ipaddress.ip_address(htonl(addrs.table[i].dwAddr)))

Пример выведет IP-адреса Ethernet-адаптера и локальной петли:

D:\network-programming-book-code> py src\book01\ch19\python\ctypes_example.py

192.168.3.254

127.0.0.1

Если функции нет в модуле, то кроме загрузчика, предоставляемого модулем, в ctypes всегда доступны функции LoadLibrary() и GetProcAddress(), которые загружают произвольную DLL обычным для ОС Windows способом.

Также стоит отметить, что в API-вызовах иногда возможно использовать значения констант из модуля socket, так как их стараются не менять, даже если константа не передается C-вызову.

Работа с DHCP

Для работы с адресами, полученными по DHCP, используются следующие функции:

#include <ipexport.h>

#include <iphlpapi.h>

typedef struct _IP_ADAPTER_INDEX_MAP

{

// Индекс сетевого интерфейса.

ULONG Index;

// Имя интерфейса.

WCHAR Name[MAX_ADAPTER_NAME];

} IP_ADAPTER_INDEX_MAP, *PIP_ADAPTER_INDEX_MAP;

// Освободить IP-адрес, ранее полученный от DHCP.

DWORD IpReleaseAddress(PIP_ADAPTER_INDEX_MAP AdapterInfo);

// Обновить аренду адреса, выданного по DHCP.

DWORD IpRenewAddress(PIP_ADAPTER_INDEX_MAP AdapterInfo);

В атрибуте имени можно указать GUID адаптера. Функции работают только с IPv4-адресами, для IPv6-адресов функций нет.

Установка некоторых параметров IP

Функция SetIpStatistics() включает или отключает IP-переадресацию и задает значение TTL по умолчанию:

#include <iphlpapi.h>

DWORD SetIpStatistics(PMIB_IPSTATS pIpStats);

ULONG SetIpStatisticsEx(PMIB_IPSTATS Statistics, ULONG Family);

Параметры функций SetIpStatistics() и SetIpStatisticsEx():

• pIpStats или Statistics — указатель на статистику.

• Family — семейство адресов AF_INET или AF_INET6.

Чтобы переключить IP-перенаправление, требуется установить параметр Forwarding:

#include <ipmib.h>

typedef struct _MIB_IPSTATS_LH

{

// Флаг переадресации.

union

{

DWORD dwForwarding;

MIB_IPSTATS_FORWARDING Forwarding;

};

// TTL по умолчанию.

DWORD dwDefaultTTL;

// Далее идет статистика принятых, отправленных, пересланных

// и отброшенных пакетов и фрагментов.

DWORD dwInReceives;

DWORD dwInHdrErrors;

DWORD dwInAddrErrors;

DWORD dwForwDatagrams;

DWORD dwInUnknownProtos;

DWORD dwInDiscards;

DWORD dwInDelivers;

DWORD dwOutRequests;

DWORD dwRoutingDiscards;

DWORD dwOutDiscards;

DWORD dwOutNoRoutes;

DWORD dwReasmTimeout;

DWORD dwReasmReqds;

DWORD dwReasmOks;

DWORD dwReasmFails;

DWORD dwFragOks;

DWORD dwFragFails;

DWORD dwFragCreates;

DWORD dwNumIf;

DWORD dwNumAddr;

DWORD dwNumRoutes;

} MIB_IPSTATS_LH, *PMIB_IPSTATS_LH;

Значение параметра dwForwarding может быть следующим:

• MIB_IP_FORWARDING — IP-пересылка включена.

• MIB_IP_NOT_FORWARDING — IP-пересылка выключена.

• MIB_USE_CURRENT_FORWARDING — использовать текущее значение параметра форвардинга, то есть не менять его.

Для TTL необходимо установить числовое значение либо специальное MIB_USE_CURRENT_TTL, чтобы не изменять TTL.

Для задания TTL существует и отдельная функция:

#include <iphlpapi.h>

DWORD SetIpTTL(UINT nTTL);

Получение информации об IP и ICMP

IP Helper предоставляет возможность поиска полезной для сетевого администрирования информации. Следующие функции извлекают статистику IP и ICMP.

Функция GetIcmpStatistics() возвращает статистику ICMP:

#include <iphlpapi.h>

ULONG GetIcmpStatistics(PMIB_ICMP Statistics);

Statistics — указатель на структуру MIB_ICMP, которая получает статистику IP-адресов для локального компьютера.

Структура содержит статистику входящих и исходящих пакетов:

typedef struct _MIBICMPINFO

{

// Статистика входящих.

MIBICMPSTATS icmpInStats;

// Статистика исходящих.

MIBICMPSTATS icmpOutStats;

} MIBICMPINFO;

typedef struct _MIB_ICMP

{

MIBICMPINFO stats;

} MIB_ICMP, *PMIB_ICMP;

Данные статистики описываются отдельной структурой:

#include <ipmib.h>

typedef struct _MIBICMPSTATS

{

// Количество сообщений.

DWORD dwMsgs;

// Количество ошибок.

DWORD dwErrors;

// Количество сообщений, не достигших места назначения.

DWORD dwDestUnreachs;

// Количество сообщений о превышении TTL.

DWORD dwTimeExcds;

// Количество сообщений об ошибках в параметрах IP-заголовков.

DWORD dwParmProbs;

// Количество сообщений о подавлении источника, которые отправляются,

// когда отправитель должен уменьшить скорость передачи.

DWORD dwSrcQuenchs;

// Количество сообщений о перенаправлении.

DWORD dwRedirects;

// Количество эхо-запросов.

DWORD dwEchos;

// Количество эхо-ответов.

DWORD dwEchoReps;

// Количество запросов о временных метках.

DWORD dwTimestamps;

// Количество ответов.

DWORD dwTimestampReps;

// Количество запросов масок подсетей.

DWORD dwAddrMasks;

// Количество ответов на запросы масок.

DWORD dwAddrMaskReps;

} MIBICMPSTATS, *PMIBICMPSTATS;

Функция GetIpStatistics() получает более подробную статистику для IP:

ULONG GetIpStatistics(PMIB_IPSTATS Statistics);

ULONG GetIpStatisticsEx(PMIB_IPSTATS Statistics, ULONG Family);

Параметры функций GetIpStatistics() и GetIpStatisticsEx():

• Statistics — указатель на структуру MIB_IPSTATS, описанную ранее.

• Family — семейство адресов AF_INET или AF_INET6.

Работа с ICMP

Хотя ОС Windows накладывает ограничения на работу с raw-сокетами, в IP Helper имеется дополнительный API для обхода этих ограничений, например для работы с ICMP.

Схема работы вызова ICMP-функций не вполне очевидна и показана на рис. 19.1.

Рис. 19.1. Схема вызова ICMP-функций

Чтобы начать с ним работать, требуется создать дескриптор, используя следующие функции:

#include <icmpapi.h>

// Для ICMP.

HANDLE IcmpCreateFile();

// Для ICMP6.

HANDLE Icmp6CreateFile();

Используя возвращенный функциями дескриптор, возможно отправлять и принимать ICMP-пакеты:

DWORD IcmpSendEcho(

HANDLE IcmpHandle,

IPAddr DestinationAddress,

LPVOID RequestData,

WORD RequestSize,

PIP_OPTION_INFORMATION RequestOptions,

LPVOID ReplyBuffer,

DWORD ReplySize,

DWORD Timeout

);

Параметры функций IcmpSendEcho():

• IcmpHandle — дескриптор ICMP, возвращенный функцией IcmpCreateFile().

• DestinationAddress — адрес назначения запроса.

• RequestData — буфер с данными запроса.

• RequestSize — размер буфера запроса.

• RequestOptions — необязательный указатель на параметры заголовка.

• ReplyBuffer — буфер ответа.

• ReplySize — размер буфера ответа.

• Timeout — время ожидания ответа.

Функция вернет количество ответов в буфере или 0 в случае ошибки. Код ошибки можно получить, вызвав функцию GetLastError().

Опции описаны следующей структурой:

#include <ipexport.h>

typedef struct ip_option_information

{

// TTL в заголовке IPv4 или количество промежуточных узлов для IPv6.

UCHAR Ttl;

// TOS для IPv4. Атрибут игнорируется.

UCHAR Tos;

// Поле флагов:

// IP_FLAG_REVERSE — маршрутизация от источника.

// IP_FLAG_DF — не фрагментировать пакет.

UCHAR Flags;

// Размер опций IP.

UCHAR OptionsSize;

// Указатель на опции IP.

PUCHAR OptionsData;

} IP_OPTION_INFORMATION, *PIP_OPTION_INFORMATION;

Функции возвращают ответы как структуры в буфере:

typedef struct icmp_echo_reply

{

// Адрес ответившего узла.

IPAddr Address;

// Код состояния IP. Например, IP_SUCCESS при успехе запроса.

ULONG Status;

// RTT.

ULONG RoundTripTime;

// Количество байтов в ответе.

USHORT DataSize;

USHORT Reserved;

// Данные ответа.

PVOID Data;

// Параметры IP-заголовка.

struct ip_option_information Options;

} ICMP_ECHO_REPLY, *PICMP_ECHO_REPLY;

В атрибуте Status хранится код состояния IP, который возвращается с ICMP-ответом:

• IP_SUCCESS — запрос выполнен успешно.

• IP_BUF_TOO_SMALL — буфер ответов слишком мал.

• IP_DEST_NET_UNREACHABLE — целевая сеть недоступна.

И так далее.

Функция IcmpSendEcho2() позволяет также отправлять ICMP-пакеты, используя при этом процедуры завершения ввода-вывода или события для асинхронной работы:

DWORD IcmpSendEcho2(

HANDLE IcmpHandle,

HANDLE Event,

PIO_APC_ROUTINE ApcRoutine,

PVOID ApcContext,

IPAddr DestinationAddress,

LPVOID RequestData,

WORD RequestSize,

PIP_OPTION_INFORMATION RequestOptions,

LPVOID ReplyBuffer,

DWORD ReplySize,

DWORD Timeout

);

Дополнительные параметры функции:

• Event — событие, которое наступает при получении ответа.

• ApcRoutine — функция, вызываемая, когда получен ICMP-ответ.

• ApcContext — параметр, который передается в функцию обратного вызова.

Существует также функция IcmpSendEcho2Ex(), позволяющая задать адрес источника.

Это полезно, чтобы выбрать, через какой интерфейс будет отправлен запрос, если узел содержит несколько таких интерфейсов.

Если функция вызывается синхронно, она вернет количество ответов в буфере или 0 в случае ошибки. При асинхронном вызове будет возвращено значение ERROR_IO_PENDING.

В буфер также будут записаны структуры ICMP_ECHO_REPLY, но за ними следуют параметры и данные. Поэтому размер буфера должен быть равен для N возможных ответов:

N * sizeof(ICMP_ECHO_REPLY) + RequestSize + 8

Здесь RequestSize — размер запроса, 8 — размер кода ошибки ICMP.

После вызова следует вызвать функцию для постобработки буфера.

Функция для постобработки:

DWORD IcmpParseReplies(LPVOID ReplyBuffer, DWORD  ReplySize);

Параметры функции IcmpParseReplies():

• ReplyBuffer — буфер, переданный в IcmpSendEcho2().

• ReplySize — размер буфера.

После вызова этой функции в буфере будут содержаться нормально инициализированные структуры ICMP_ECHO_REPLY.

Для ICMP6 предоставляется функция, аналогичная функции для ICMP:

DWORD Icmp6SendEcho2(

HANDLE IcmpHandle,

HANDLE Event,

PIO_APC_ROUTINE ApcRoutine,

PVOID ApcContext,

sockaddr_in6 *SourceAddress,

sockaddr_in6 *DestinationAddress,

LPVOID RequestData,

WORD RequestSize,

PIP_OPTION_INFORMATION RequestOptions,

LPVOID ReplyBuffer,

DWORD ReplySize,

DWORD Timeout

);

Видно, что для функции IcmpSendEcho2() отличается тип адресов.

Ответ, возвращаемый в массиве, для IPv6 несколько проще:

typedef struct _IPV6_ADDRESS_EX

{

USHORT sin6_port;

ULONG  sin6_flowinfo;

USHORT sin6_addr[8];

ULONG  sin6_scope_id;

} IPV6_ADDRESS_EX, *PIPV6_ADDRESS_EX;

typedef struct icmpv6_echo_reply_lh

{

// Адрес.

IPV6_ADDRESS_EX Address;

// Код состояния.

ULONG Status;

unsigned int RoundTripTime;

} ICMPV6_ECHO_REPLY_LH, *PICMPV6_ECHO_REPLY_LH;

После вызова функции также необходимо выполнить постобработку буфера, вызвав:

DWORD Icmp6ParseReplies(LPVOID ReplyBuffer, DWORD  ReplySize);

Она вернет 1 в случае успеха или 0 в случае ошибки. Если запрос ICMP завершился успешно, атрибут Status будет равен IP_SUCCESS, в противном случае IP_TTL_EXPIRED_TRANSIT.

Внимание! Для функции IcmpSendEcho() вызывать функцию постобработки буфера не требуется.

Когда работа с ICMP завершена, хэндл требуется закрыть, вызвав функцию:

bool IcmpCloseHandle(HANDLE IcmpHandle);

После успешного закрытия хэндла функция вернет истину.

Ping

Рассмотрим, как использовать IP Helper API и ICMP API, в частности, на примере утилиты ping. Пример утилиты, работающий через raw-сокеты, также скомпилируется и будет работать на ОС Windows, но ICMP API позволяет выполнять отправку ICMP-пакетов просто и без прав администратора.

Схема работы ping, реализованной далее, показана на рис. 19.2.

Рис. 19.2. Схема работы ping

Поскольку мы используем функцию IcmpSendEcho(), а не IcmpSendEcho2(), вызывать IcmpParseReplies() не требуется. Функция IcmpCloseFile(), показанная на рисунке, будет вызвана неявно.

Включим необходимые заголовочные файлы:

...

extern "C"

{

#include <ipexport.h>

#include <icmpapi.h>

#include <icmpapi.h>

}

Чтобы автоматически закрывать хэндл при выходе из области видимости, инкапсулируем его в класс:

struct IcmpHandle

{

// Создать хэндл через IcmpCreateFile().

IcmpHandle() : handle(IcmpCreateFile())

{

if (INVALID_HANDLE_VALUE == handle)

{

throw std::system_error(GetLastError(), std::system_category(),

"IcmpCreateFile() error!");

}

}

~IcmpHandle()

{

// Закрыть хэндл.

if (!IcmpCloseHandle(handle))

{

std::cerr

<< "IcmpCloseHandle failed with code " << GetLastError()

<< std::endl;

}

}

// Оператор преобразования к типу HANDLE.

operator HANDLE() const noexcept { return handle; }

private:

const HANDLE handle;

};

Определим несколько перечислений, которые содержат различные опции ping:

// Тип функции для расчета доступности узла.

enum class t_ping_func

{

tpf_avg, tpf_max, tpf_min

};

// Опции пинга.

enum ping_options : unsigned char

{

// Проверка доступности.

PING_OPT_AVAIL = 0x01,

// Увеличить уровень подробности вывода.

PING_OPT_VERBOSE = 0x02,

// Не вызывать Sleep() между пингами.

PING_OPT_NOSLEEP = 0x04

};

Зададим несколько значений по умолчанию:

// Задержка после отправки запроса в миллисекундах

constexpr auto PING_DEF_DELAY = 1000;

// Количество пакетов.

constexpr auto PING_DEF_COUNT = 3;

// Размер данных пакета в байтах.

constexpr auto PING_DEF_DATA_SIZE = 32;

// Функция расчета по умолчанию.

constexpr auto PING_DEF_FUNC = t_ping_func::tpf_max;

//

// Ограничения.

//

// Максимальный размер данных.

constexpr auto PING_MAX_DATA_SIZE = 0xffe0;

// Максимальное время пинга (максимальное время повторной передачи

// около 4 минут)

constexpr auto PING_MAX_TIME = 250000;

Определим структуру, которая содержит итоговые настройки:

struct t_ping_data

{

// Узел для ping.

char* host{0};

// Поле флагов, которые инициализируются опциями из ping_options,

// такими как PING_OPT_VERBOSE.

DWORD opts{0};

// Функция для расчета RTT.

t_ping_func ping_func{t_ping_func::tpf_avg};

// Количество отправляемых пакетов.

int count{0};

// Задержка после отправки запроса.

int delay{0};

// Размер буфера данных.

size_t buf_sz{0};

// Шаблон.

char pattern{0};

};

По результатам анализа опций и на основе значений по умолчанию заполним ее параметрами позже.

Далее рассмотрим служебные функции. Первая создает буфер ответа:

PICMP_ECHO_REPLY create_reply_buffer(std::vector<char> &buf,

std::vector<char> &reply_holder)

{

// Создать в памяти структуру ответа.

auto p_reply = new(reply_holder.data()) ICMP_ECHO_REPLY;

// Структура уже обнулена.

p_reply->Data = buf.data();

assert(buf.size() <= (std::numeric_limits<USHORT>::max)());

p_reply->DataSize = static_cast<USHORT>(buf.size());

return p_reply;

}

Следующая функция возвращает время ожидания ответа, основываясь на том, какой тип расчета доступности узла выбран:

int get_end_time(DWORD end_time, const t_ping_data * const wping,

DWORD rtt)

{

// Выбрать тип расчета ожидания.

switch (wping->ping_func)

{

case t_ping_func::tpf_max:

return max(rtt, end_time);

break;

case t_ping_func::tpf_avg:

return end_time + rtt;

break;

case t_ping_func::tpf_min:

return min(rtt, end_time);

break;

default:

break;

}

return 0;

}

Основная работа выполняется функцией ping(). Сначала формируются буферы запроса и ответа:

bool ping(const t_ping_data * const wping)

{

// Опции IP.

IP_OPTION_INFORMATION ip_info{};

// Буфер запроса.

std::vector<char> buf;

assert(wping);

// Сформировать буфер запроса.

if (wping->buf_sz > 0)

{

if (wping->buf_sz > PING_MAX_DATA_SIZE)

{

std::cerr

<< "Error: maximum ping data size = " << PING_MAX_DATA_SIZE

<< ", but you asked size = " << wping->buf_sz << std::endl;

throw std::logic_error("Maximum ping data size exceeded!");

}

buf.resize(wping->buf_sz);

if (wping->opts & PING_OPT_VERBOSE)

std::cout << "Data pattern: 0x" << wping->pattern << std::endl;

// Заполнить буфер шаблоном.

std::fill(buf.begin(), buf.begin() + wping->buf_sz, wping->pattern);

}

// Буфер ответа.

std::vector<char> reply_buf(sizeof(ICMP_ECHO_REPLY) + buf.size());

// Сформировать буфер.

auto echo_reply = create_reply_buffer(buf, reply_buf);

Теперь создадим дескриптор ICMP, который будем использовать для обмена сообщениями:

// Создать хэндл ICMP.

IcmpHandle hndl_icmp;

// Получить адреса клиента.

auto addrs = socket_wrapper::get_client_info(wping->host, 0, SOCK_STREAM,

AF_INET);

addrinfo *ai = addrs.get();

// Требуется IPv4-адрес.

while (ai->ai_family != AF_INET)

{

if (!ai->ai_next) throw std::logic_error(

"Can't resolve IPv4 for the host!");

ai = ai->ai_next;

}

// IPv4-адрес.

sockaddr_in ia_dest;

std::copy(ai->ai_addr, ai->ai_addr + 1,

reinterpret_cast<sockaddr*>(&ia_dest));

if (INADDR_NONE == ia_dest.sin_addr.s_addr ||

ia_dest.sin_family != AF_INET)

{

if (wping->opts & PING_OPT_VERBOSE)

std::cerr << "Can't lookup destination!" << std::endl;

throw std::logic_error("Can't lookup destination!");

}

Сформируем буфер с IP-адресом и заполним структуру опций IP-пакета:

// Буфер с адресом.

std::string ip_buf(INET_ADDRSTRLEN, 0);

bool ret_status = false;

// Время ответа.

int end_time;

// Количество ответов, пришедших на запросы.

DWORD good_packets_cnt = 0;

// Некоторые разумные значения по умолчанию.

ip_info.Ttl = 255;

ip_info.Tos = 0;

ip_info.Flags = 0;

ip_info.OptionsSize = 0;

ip_info.OptionsData = nullptr;

if (wping->opts & PING_OPT_VERBOSE)

{

std::cout

<< "Count = " << wping->count

<< "\nSize  = " << wping->buf_sz + sizeof(ICMP_ECHO_REPLY)

<< "\nDelay = " << wping->delay

<< "\nSending to \""

<< inet_ntop(AF_INET, &ia_dest.sin_addr, ip_buf.data(),

ip_buf.size())

<< "\""

<< std::endl;

}

// Формирование предельного времени для проверки доступности.

end_time = (t_ping_func::tpf_min == wping->ping_func) ? PING_MAX_TIME : 0;

Наконец, запустим цикл отправки запросов и получения ответов:

for (int i = 0; i < wping->count; ++i)

{

echo_reply->Status = IP_SUCCESS;

// Отправить эхо-запрос ICMP и получить ответ:

DWORD rep_cnt = IcmpSendEcho(

hndl_icmp,

reinterpret_cast<const sockaddr_in*>(&ia_dest)->sin_addr.s_addr,

buf.data(), static_cast<WORD>(buf.size()),

&ip_info, echo_reply,

static_cast<DWORD>(sizeof(ICMP_ECHO_REPLY) + buf.size()),

wping->delay

);

// Ожидать после отправки.

if (!(wping->opts & PING_OPT_NOSLEEP)) Sleep(wping->delay);

В случае успешного вызова функции будет выполнен цикл по ответам:

if (rep_cnt > 0 && (IP_SUCCESS == echo_reply->Status))

{

// Для каждого ответа.

while (rep_cnt--)

{

// Получить время, в течение которого узел будет считаться

// доступным.

end_time = get_end_time(end_time, wping,

echo_reply->RoundTripTime);

if (wping->opts & PING_OPT_VERBOSE)

{

std::cout

<< "Received from: "

<< inet_ntop(AF_INET, &echo_reply->Address,

ip_buf.data(), ip_buf.size())

<< "\nStatus: " << echo_reply->Status

<< "\nRoundtrip time: "

<< echo_reply->RoundTripTime << "ms"

<< "\nTTL: "

<< static_cast<int>(echo_reply->Options.Ttl)

<< "\nBytes: " << echo_reply->DataSize

<< std::endl;

}

// Количество пакетов, на которые пришли ответы.

++good_packets_cnt;

}

}

Если узел не ответил, увеличим время ожидания между запросом и ответом:

else

{

// Узел не ответил.

if (wping->opts & PING_OPT_VERBOSE)

{

std::cout

<< "Host " << wping->host

<< " (resolved:\""

<< inet_ntop(AF_INET, &ia_dest.sin_addr,

ip_buf.data(), ip_buf.size())

<< "\") doesn't respond!"

<< std::endl;

}

// Скорректировать время ожидания.

end_time += get_end_time(end_time, wping, wping->delay);

}

}

ret_status = good_packets_cnt != 0;

Время используется для проверки на доступность. В конце функции хэндл для работы с ICMP требуется закрыть:

if (!(wping->opts & PING_OPT_AVAIL))

{

if (ret_status)

{

// Проверка узла на доступность.

std::cout

<< ((t_ping_func::tpf_avg != wping->ping_func) ? end_time :

static_cast<float>(end_time) / (good_packets_cnt + 1))

<< std::endl;

}

else std::cout << "-1" << std::endl;

}

// Хэндл ICMP закроется при выходе класса из области видимости.

return ret_status;

}

Настройка структуры и вызов функции ping() производятся в функции main():

int main(int argc, char **argv)

{

if (argc != 2)

{

std::cout << "Usage: " << argv[0] << " <hostname>" << std::endl;

return EXIT_FAILURE;

}

socket_wrapper::SocketWrapper sw;

struct t_ping_data wping;

// Настройка пинга, которая может быть выполнена через

// аргументы командной строки.

wping.opts       = PING_OPT_VERBOSE; // PING_OPT_NOSLEEP, PING_OPT_AVAIL

wping.count      = PING_DEF_COUNT;

wping.buf_sz     = PING_DEF_DATA_SIZE;

wping.delay      = PING_DEF_DELAY;

wping.ping_func  = PING_DEF_FUNC;

wping.host     = argv[1];

wping.pattern  = 'a';

// Запуск пинга либо в режиме проверки доступности, либо в обычном режиме.

if (wping.opts & PING_OPT_AVAIL)

std::cout << (ping(&wping) ? 1 : 0) << std::endl;

else ping(&wping);

return EXIT_SUCCESS;

}

Запустим приложение. Вывод будет похож на следующий:

D:\build\bin> b01-ch19-windows-ping.exe localhost

Data pattern: 0xa

Count = 3

Size  = 72

Delay = 1000

Sending to "127.0.0.1"

Received from: 127.0.0.1

Status: 0

Roundtrip time: 0ms

TTL: 128

Bytes: 32

Received from: 127.0.0.1

Status: 0

Roundtrip time: 0ms

TTL: 128

Bytes: 32

Received from: 127.0.0.1

Status: 0

Roundtrip time: 0ms

TTL: 128

Bytes: 32

0

Видим, что ping выводит состояние, RTT, TTL и количество байтов данных.

Использование ARP

Для работы с ARP в IP Helper также поддерживается набор функций.

Как было описано в главе 10, ARP-таблица содержит сопоставление IP-адресов с физическими адресами:

#include <ipmib.h>

// Элемент таблицы.

typedef struct _MIB_IPNETROW

{

// Индекс сетевого адаптера.

DWORD dwIndex;

// Длина физического адреса в байтах.

DWORD dwPhysAddrLen;

// Физический адрес.

BYTE bPhysAddr[8];

// IPv4-адрес.

DWORD dwAddr;

// Тип ARP-записи.

union

{

DWORD dwType;

MIB_IPNET_TYPE Type;

};

} MIB_IPNETROW, *PMIB_IPNETROW;

// Структура, описывающая ARP-таблицу.

typedef struct _MIB_IPNETTABLE

{

// Количество элементов в таблице.

DWORD dwNumEntries;

// Таблица элементов.

MIB_IPNETROW table[ANY_SIZE];

} MIB_IPNETTABLE, *PMIB_IPNETTABLE;

Тип ARP-записи:

• 0x00000001 или MIB_IPNET_TYPE_OTHER — другой узел.

• 0x00000002 или MIB_IPNET_TYPE_INVALID — некорректная ARP-запись. Недоступная или неполная.

• 0x00000003 или MIB_IPNET_TYPE_DYNAMIC — динамическая ARP-запись.

• 0x00000004 или MIB_IPNET_TYPE_STATIC — статическая ARP-запись.

Функция для получения ARP-таблицы:

#include <iphlpapi.h>

ULONG GetIpNetTable(PMIB_IPNETTABLE IpNetTable, PULONG SizePointer,

bool Order);

Параметры функции GetIpNetTable():

• IpNetTable — указатель на буфер, который получает ARP-таблицу.

• SizePointer — количество байтов в буфере, на который указывает IpNetTable. Если буфер меньше возвращаемого размера структуры, функция установит этот параметр в требуемое значение размера и вернет код ошибки ERROR_INSUFFICIENT_BUFFER.

• Order — если истина, возвращаемая таблица будет отсортирована в порядке возрастания IP-адресов.

// Создать новую ARP-запись.

DWORD CreateIpNetEntry(PMIB_IPNETROW pArpEntry);

// Изменить существующую ARP-запись.

DWORD SetIpNetEntry(PMIB_IPNETROW pArpEntry);

// Удалить ARP-запись.

DWORD DeleteIpNetEntry(PMIB_IPNETROW pArpEntry);

// Удалить все записи в ARP-таблице для интерфейса с указанным индексом.

DWORD FlushIpNetTable(DWORD dwIfIndex);

В pArpEntry для изменения либо добавления записи таблицы необходимо задать все атрибуты, для удаления — хотя бы поля dwIndex и dwAddr.

Очищать ARP-таблицу, создавать и менять в ней записи может только администратор.

Чтобы объединить две сети в одну, можно использовать технику Proxy ARP. Она заключается в том, что на ARP-запрос IP-адреса узла из другой подсети отвечает маршрутизатор. Он подставляет свой MAC-адрес и работает как прокси.

Для управления записями Proxy ARP существуют функции, позволяющие добавлять и удалять записи:

DWORD CreateProxyArpEntry(DWORD dwAddress, DWORD dwMask, DWORD dwIfIndex);

DWORD DeleteProxyArpEntry(DWORD dwAddress, DWORD dwMask, DWORD dwIfIndex);

Параметры функций CreateProxyArpEntry() и DeleteProxyArpEntry():

• dwAddress — IPv4-адрес, для которого локальный узел выступает прокси-сервером.

• dwMask — маска подсети для IPv4-адреса, указанного в dwAddress.

• dwIfIndex — индекс интерфейса.

Используя функцию SendARP(), можно отправить запрос ARP в локальную сеть:

DWORD SendARP(IPAddr DestIP, IPAddr SrcIP, PVOID pMacAddr, PULONG PhyAddrLen);

Параметры функции SendARP():

• DestIP — IPv4-адрес назначения в виде структуры IPAddr.

• SrcIP — исходный IPv4-адрес в виде структуры IPAddr.

• pMacAddr — указатель на буфер возвращаемого значения, в который будет записан физический адрес для DestIP.

• PhyAddrLen — указатель на размер выходного буфера в байтах. Функция запишет по этому адресу количество байтов, реально записанных в буфер.

В случае успеха все функции возвращают код NO_ERROR.

Управление маршрутизацией

Работа с таблицей маршрутизации выполняется не через вызовы ioctl, как в Unix-подобных системах, а через функции IP Helper.

Для работы с маршрутами предоставляются такие функции, как CreateForwardEntry() и CreateForwardEntry2() для создания маршрутов в таблице маршрутизации. Существуют и аналогичные функции для получения, изменения, удаления записей маршрутов.

Включить маршрутизацию позволяет функция EnableRouter(), выключить — функция UnenableRouter().

Функция GetBestRoute() позволяет найти оптимальный маршрут до указанного адреса IP, а функция GetRTTAndHopCount() определяет RTT до адреса.

Отдельное подмножество API-функций CreateIpNetEntry2(), GetIpNetEntry2(), SetIpNetEntry2(), GetIpNetTable2(), DeleteIpNetEntry2(), FlushIpNetTable2() и прочие из группы IpNet-функций служат для управления адресами соседей.

Используя построенную таблицу, можно получить физические адреса по IP-адресам, вызвав функцию ResolveIpNetEntry2() или более старую ResolveNeighbor().

Получение уведомлений о сетевых событиях

Функция NotifyAddrChange() используется для запроса уведомлений о любых изменениях в сопоставлении IP-адресов и интерфейсов на узле.

Уведомления о любых изменениях таблицы маршрутизации позволяет запросить функция NotifyRouteChange():

#include <iphlpapi.h>

// Уведомлять при изменении адресов.

DWORD NotifyAddrChange(PHANDLE Handle, LPOVERLAPPED overlapped);

// Уведомлять при каждом изменении маршрутов.

DWORD NotifyRouteChange(PHANDLE Handle, LPOVERLAPPED overlapped);

Параметры функций NotifyAddrChange() и NotifyRouteChange():

• Handle — указатель на переменную, которая получает дескриптор для использования в асинхронном уведомлении.

• overlapped — указатель на структуру OVERLAPPED для уведомления вызывающего объекта.

Уведомления просто сигнализируют об изменениях. Чтобы понять, что изменилось, используются другие вспомогательные функции, такие как GetIPAddrTable() и GetBestRoute().

Отменить получение уведомлений можно следующей функцией:

#include <iphlpapi.h>

bool CancelIPChangeNotify(LPOVERLAPPED notifyOverlapped);

В notifyOverlapped должен быть передан указатель на ту же структуру, что использовалась в предыдущих функциях для включения уведомлений.

Реализуем пример, который будет ожидать уведомлений об изменении таблицы маршрутизации и затем выводить лучший маршрут до заданного сервера:

int main()

{

// Создать событие, которое наступит при приходе уведомления.

OVERLAPPED overlap = {.hEvent = WSACreateEvent()};

if (WSA_INVALID_EVENT == overlap.hEvent)

{

std::cerr

<< "WSACreateEvent error: " << WSAGetLastError() << std::endl;

return EXIT_FAILURE;

}

HANDLE hand = nullptr;

// Включить уведомления.

if (NO_ERROR != NotifyRouteChange(&hand, &overlap))

{

if (WSAGetLastError() != WSA_IO_PENDING)

{

std::cerr

<< "NotifyRouteChange error: "

<< WSAGetLastError() << std::endl;

return EXIT_FAILURE;

}

}

Сначала мы создали новое событие и записали его дескриптор в структуру overlap. Затем включили уведомления, использовав эту структуру. Теперь, когда таблица маршрутизации изменится, событие будет активировано.

Следующий шаг — нахождение IP-адреса интерфейса, от которого должен быть построен маршрут. Сделать это можно несколькими способами. Например, получив адаптеры вызовом функции GetAdaptersAddresses(), как мы уже делали ранее. Но в данном примере мы для этого вызовем функцию GetIpAddrTable():

ULONG mib_size = 0;

// Первый вызов приведет к ошибке, но функция установит требуемый

// размер буфера.

if (const auto result = GetIpAddrTable(nullptr, &mib_size, false);

ERROR_INSUFFICIENT_BUFFER != result)

{

// Завершиться, если это другая ошибка, не связанная с буфером.

std::cerr

<< "GetIpAddrTable() failed with code " << result << std::endl;

return EXIT_FAILURE;

}

// Создать буфер заданного размера.

std::vector<MIB_IPADDRTABLE> mib_table(

mib_size / sizeof(MIB_IPADDRTABLE) + 1);

// Повторно вызвать функцию для получения адресов.

if (const auto result = GetIpAddrTable(mib_table.data(), &mib_size,

false);

NO_ERROR != result)

{

std::cerr

<< "GetIpAddrTable() failed with code " << result << std::endl;

return EXIT_FAILURE;

}

std::string ip_addr(INET_ADDRSTRLEN, 0);

uint32_t my_ip_addr = 0;

// Выполнить итерацию по таблице адресов.

for (size_t i = 0; i < mib_table[0].dwNumEntries; ++i)

{

auto row = mib_table[0].table[i];

// Если это интерфейс локальной петли, ищем дальше.

if (INET_IS_ADDR_LOOPBACK(AF_INET, &row.dwAddr)) continue;

// Адрес найден, его необходимо сохранить и выйти из цикла.

my_ip_addr = row.dwAddr;

std::cout

<< "My IP = "

<< inet_ntop(AF_INET, &row.dwAddr, ip_addr.data(), ip_addr.size())

<< std::endl;

break;

}

При вызове этой функции, если размер буфера недостаточен, будет возвращен код ошибки ERROR_INSUFFICIENT_BUFFER. Если это так, увеличиваем размер буфера и делаем вызов повторно.

Когда адреса получены, следует пропустить все неподходящие интерфейсы, а нужный адрес сохранить.

В коде примера выше проводится минимальная проверка, так как в данном тестовом примере известно, что у машины, на которой он запускается, всего два интерфейса: локальная петля и Ethernet. Проверки в реальном приложении должны быть строже и обширнее.

Теперь вызовем функцию GetBestRoute() для получения маршрута к заданному серверу и будем ожидать события изменения таблицы маршрутизации:

// Адрес известного DNS-сервера Google.

uint32_t google_dns_addr = 0;

inet_pton(AF_INET, "8.8.8.8", &google_dns_addr);

MIB_IPFORWARDROW fr_row;

// Получить лучший маршрут от нашего интерфейса до сервера.

if (!NO_ERROR == GetBestRoute(google_dns_addr, my_ip_addr, &fr_row))

{

std::cerr << "Could not get best route" << std::endl;

return EXIT_FAILURE;

}

// Выведем номер интерфейса, а также следующий промежуточный узел.

std::cout

<< "1. Iface index: " << fr_row.dwForwardIfIndex

<< " ["

<< inet_ntop(AF_INET, &fr_row.dwForwardNextHop, ip_addr.data(),

ip_addr.size())

<< "]"

<< std::endl;

// Ожидать наступления события изменения таблицы маршрутизации.

if (WAIT_OBJECT_0 == WaitForSingleObject(overlap.hEvent, INFINITE))

std::cout << "Routing table changed." << std::endl;

После того как событие наступило, отменим получение нотификаций и вызовем функцию получения лучшего маршрута повторно, чтобы увидеть изменения:

// Отменить существующее ожидание.

CancelIPChangeNotify(&overlap);

// Получить лучший маршрут от нас до сервера.

if (NO_ERROR != GetBestRoute(google_dns_addr, my_ip_addr, &fr_row))

{

std::cerr << "Could not get best route" << std::endl;

return EXIT_FAILURE;

}

std::cout

<< "2. Iface index: " << fr_row.dwForwardIfIndex << " ["

<< inet_ntop(AF_INET, &fr_row.dwForwardNextHop, ip_addr.data(),

ip_addr.size())

<< "]" << std::endl;

return EXIT_SUCCESS;

}

Теперь запустим пример и выполним действия, приводящие к изменению таблицы. Например, очистим ее следующей командой:

D:\build\bin> route -f

Запущенный пример будет ожидать наступления события и после выполнения очистки таблицы завершится:

D:\build\bin> b01-ch19-route-notification.exe

My IP = 192.168.3.254

1. Iface index: 6 [192.168.3.1]

Routing table changed.

Второй маршрут не был построен, так как после очистки в таблице маршруты отсутствуют. Теперь запустим пример второй раз и восстановим таблицу маршрутизации. Простейший способ это сделать — вызвать переподключение, которое произойдет, если выдернуть из адаптера сетевой кабель и затем воткнуть его обратно. Либо выключить адаптер программно и включить снова.

В примере мы видим, что в первый раз маршрут не был построен, поскольку таблица была пуста:

D:\build\bin> b01-ch19-route-notification.exe

My IP = 192.168.3.254

Routing table changed.

2. Iface index: 6 [192.168.3.1]

Но после ее изменения маршруты добавились, событие наступило и мы смогли получить маршрут к узлу.

В данном случае следующий узел маршрута всегда один и тот же, потому что сетевой адаптер подключен к сети через маршрутизатор, IP-адрес которого — 192.168.3.1.

Функция NotifyIpInterfaceChange() требуется для получения уведомлений об изменениях IP-интерфейсов:

#include <netioapi.h>

NETIO_STATUS NotifyIpInterfaceChange(

ADDRESS_FAMILY Family,

PIPINTERFACE_CHANGE_CALLBACK Callback,

PVOID CallerContext,

bool InitialNotification,

HANDLE *NotificationHandle

);

Параметры функции NotifyIpInterfaceChange():

• Family — AF_UNSPEC, AF_INET или AF_INET6.

• Callback — указатель на функцию, вызываемую при изменении интерфейса.

• CallerContext — значение, которое может задать пользователь для передачи в функцию обратного вызова.

• InitialNotification — истина, если должно быть отправлено уведомление при инициализации.

• NotificationHandle — указатель на хэндл, который будет использован для уведомления.

Помимо данной функции существуют те, которые включают уведомления от Teredo, но мы их не рассматриваем.

Чтобы отменить уведомления, которые были запрошены этой функцией, а также другими из netioapi.h, необходимо вызвать следующую функцию:

#include <netioapi.h>

NETIO_STATUS CancelMibChangeNotify2(HANDLE NotificationHandle);

Получение информации о транспортных протоколах

IP Helper позволяет получить доступ к информации о транспортных протоколах на локальном узле.

TCP

Следующие функции возвращают таблицу соединений TCP:

#include <iphlpapi.h>

// Получить таблицу TCP-соединений поверх IPv4.

ULONG GetTcpTable(PMIB_TCPTABLE TcpTable, PULONG SizePointer, bool Order);

ULONG GetTcpTable2(PMIB_TCPTABLE2 TcpTable, PULONG SizePointer, bool Order);

// Получить таблицу TCP-соединений поверх IPv6.

ULONG GetTcp6Table(PMIB_TCP6TABLE TcpTable, PULONG SizePointer, bool Order);

ULONG GetTcp6Table2(PMIB_TCP6TABLE2 TcpTable, PULONG SizePointer, bool Order);

Параметры функций GetTcpTable(), GetTcpTable2(), GetTcp6Table(), GetTcp6Table2():

• TcpTable — указатель на буфер, в который будет записана таблица TCP-подключений.

• SizePointer — количество байтов во входном буфере. Если размер буфера недостаточен, сюда будет записан требуемый размер и возвращен код ERROR_INSUFFICIENT_BUFFER.

• Order — если истина, подключения будут отсортированы в порядке «локальный IP-адрес, порт, удаленный адрес, порт».

Таблица представляет собой массив структур, который содержит адреса, порты и состояние подключения:

#include <tcpmib.h>

// Ряд таблицы.

typedef struct _MIB_TCPROW_LH

{

// Состояние подключения.

union

{

DWORD dwState;

MIB_TCP_STATE State;

};

// Локальный IPv4-адрес. Может быть нулевым, если сокет прослушивающий.

DWORD dwLocalAddr;

// Локальный порт. Используются младшие 16 бит слова.

DWORD dwLocalPort;

// Адрес удаленного абонента. Если сокет прослушивающий, не используется.

DWORD dwRemoteAddr;

// Порт удаленного абонента. К нему применимы ограничения выше.

DWORD dwRemotePort;

} MIB_TCPROW, MIB_TCPROW_LH, *PMIB_TCPROW_LH;

typedef struct _MIB_TCPTABLE

{

// Количество элементов таблицы.

DWORD dwNumEntries;

// Ряды таблицы.

MIB_TCPROW table[ANY_SIZE];

} MIB_TCPTABLE, *PMIB_TCPTABLE;

Поле State, которое отражает состояние TCP-подключения, может принимать следующие значения:

• MIB_TCP_STATE_CLOSED — TCP-подключение находится в состоянии CLOSED. В действительности записей с таким состоянием в таблице нет. Их просто не существует.

• MIB_TCP_STATE_LISTEN — состояние прослушивания ожидает запрос на подключение.

• MIB_TCP_STATE_SYN_SENT — состояние SYN-SENT. Ожидается подключение после отправки SYN.

• MIB_TCP_STATE_SYN_RCVD — состояние SYN-RECEIVED, ожидается подтверждение запроса на подключение после отправки SYN.

• MIB_TCP_STATE_ESTAB — TCP-подключение установлено. Данные могут быть доставлены пользователю.

• MIB_TCP_STATE_FIN_WAIT1 — состояние FIN-WAIT-1. Ожидается запрос на завершение подключения от удаленного абонента или подтверждение ранее отправленного запроса на отключение.

• MIB_TCP_STATE_FIN_WAIT2 — состояние FIN-WAIT-2. Ожидается запрос на завершение подключения от удаленного абонента.

• MIB_TCP_STATE_CLOSE_WAIT — состояние CLOSE-WAIT. Ожидается запрос на завершение подключения от пользователя.

• MIB_TCP_STATE_CLOSING — состояние CLOSING. Ожидается подтверждение запроса на завершение подключения от удаленного абонента.

• MIB_TCP_STATE_LAST_ACK — состояние LAST-ACK. Ожидается подтверждение запроса на завершение подключения, ранее отправленного удаленному абоненту.

• MIB_TCP_STATE_TIME_WAIT — состояние TIME-WAIT. Ожидать время таймера, чтобы убедиться, что удаленный абонент получил подтверждение запроса на завершение подключения.

• MIB_TCP_STATE_DELETE_TCB — состояние удаления TCB, блока управления передачей.

В строки таблицы PMIB_TCPTABLE2 добавляются еще два поля: PID владеющего соединением процесса и состояние разгрузки на сетевой адаптер.

Эта таблица выглядит так:

typedef struct _MIB_TCPROW2

{

// Поля те же, что и в структуре MIB_TCP_TABLE.

DWORD dwState;

// Адреса и порты.

DWORD dwLocalAddr;

DWORD dwLocalPort;

DWORD dwRemoteAddr;

DWORD dwRemotePort;

// Идентификатор процесса, к которому привязан контекст соединения.

DWORD dwOwningPid;

// Состояние разгрузки соединения.

TCP_CONNECTION_OFFLOAD_STATE dwOffloadState;

} MIB_TCPROW2, *PMIB_TCPROW2;

Значения состояния разгрузки dwOffloadState:

• TcpConnectionOffloadStateInHost — подключение не разгружено и обрабатывается сетевым стеком на локальном узле.

• TcpConnectionOffloadStateOffloading — подключение в процессе разгрузки, которая пока не завершена.

• TcpConnectionOffloadStateOffloaded — подключение разгружено и обрабатывается контроллером сетевого интерфейса.

• TcpConnectionOffloadStateUploading — TCP-подключение передается обратно в сетевой стек узла, но процесс восстановления подключения к узлу не завершен.

• TcpConnectionOffloadStateMax — недопустимое максимально возможное значение для типа перечисления.

Таблицы для IPv6-версий функций такие же, как и для IPv4, но типы адресов не DWORD, а IN6_ADDR.

Получать выборочные данные о подключениях можно через следующую функцию:

DWORD GetExtendedTcpTable(

PVOID pTcpTable, PDWORD pdwSize, bool bOrder, ULONG ulAf,

TCP_TABLE_CLASS TableClass, ULONG Reserved

);

Параметры функции GetExtendedTcpTable():

• pTcpTable — указатель на буфер для таблицы.

• pdwSize — размер буфера. Сюда же будет записан требуемый размер, если переданного недостаточно.

• bOrder — флаг включения упорядочивания записей в таблице.

• ulAf — запрашиваемое семейство адресов: AF_INET или AF_INET6.

• TableClass — тип извлекаемых записей:

• TCP_TABLE_BASIC_LISTENER — таблица MIB_TCPTABLE, содержащая прослушивающие сокеты.

• TCP_TABLE_BASIC_CONNECTIONS — таблица MIB_TCPTABLE, содержащая все подключенные TCP-соединения.

• TCP_TABLE_BASIC_ALL — таблица MIB_TCPTABLE, содержащая все конечные точки TCP на локальном компьютере.

• TCP_TABLE_OWNER_PID_LISTENER — MIB_TCPTABLE_OWNER_PID или MIB_TCP6TABLE_OWNER_PID, содержащая все прослушивающие TCP-сокеты.

• TCP_TABLE_OWNER_PID_CONNECTIONS — MIB_TCPTABLE_OWNER_PID или MIB_TCP6TABLE_OWNER_PID, содержащая все подключенные TCP-сокеты.

• TCP_TABLE_OWNER_PID_ALL — MIB_TCPTABLE_OWNER_PID или MIB_TCP6TABLE_OWNER_PID, содержащая все TCP-сокеты.

• TCP_TABLE_OWNER_MODULE_LISTENER — структура MIB_TCPTABLE_OWNER_MODULE или MIB_TCP6TABLE_OWNER_MODULE, содержащая все прослушивающие сокеты.

• TCP_TABLE_OWNER_MODULE_CONNECTIONS — структура MIB_TCPTABLE_OWNER_MODULE или MIB_TCP6TABLE_OWNER_MODULE, содержащая все подключенные TCP-сокеты.

• TCP_TABLE_OWNER_MODULE_ALL — структура MIB_TCPTABLE_OWNER_MODULE или MIB_TCP6TABLE_OWNER_MODULE, содержащая все TCP-сокеты.

• Reserved — зарезервировано и должно быть равно 0.

Доступные запрашиваемые записи зависят от семейства адресов. Подробнее см. в MSDN148.

Для получения информации о статистике TCP-подключений используются следующие функции:

ULONG GetTcpStatistics(PMIB_TCPSTATS Statistics);

ULONG GetTcpStatisticsEx(PMIB_TCPSTATS Statistics, ULONG Family);

ULONG GetTcpStatisticsEx2(PMIB_TCPSTATS2 Statistics, ULONG Family);

Параметры функций GetTcpStatistics(), GetTcpStatisticsEx(), GetTcpStatisticsEx2():

• Family — запрашиваемое семейство протоколов AF_INET либо AF_INET6.

• Statistics — указатель на структуру MIB_TCPSTATS, в которую будет записана статистика TCP.

Они возвращают следующую структуру:

#include <iphlpapi.h>

typedef struct _MIB_TCPSTATS_LH

{

// Алгоритм ожидания повторной передачи — RTO.

union

{

DWORD dwRtoAlgorithm;

TCP_RTO_ALGORITHM RtoAlgorithm;

};

// Минимальное RTO в миллисекундах.

DWORD dwRtoMin;

// Максимальное RTO в миллисекундах.

DWORD dwRtoMax;

// Максимальное количество подключений. Если -1 — число переменное.

DWORD dwMaxConn;

// Количество активных соединений клиент -> сервер.

DWORD dwActiveOpens;

// Количество подключений к серверному сокету, на котором вызван listen().

DWORD dwPassiveOpens;

// Количество неудачных подключений.

DWORD dwAttemptFails;

// Количество установленных подключений, которые были сброшены.

DWORD dwEstabResets;

// Количество установленных на данный момент подключений.

DWORD dwCurrEstab;

// Количество полученных сегментов.

DWORD dwInSegs;

// Количество отправленных сегментов.

DWORD dwOutSegs;

// Количество перенаправленных сегментов.

DWORD dwRetransSegs;

// Количество ошибок приема.

DWORD dwInErrs;

// Количество сегментов, переданных с флагом RST.

DWORD dwOutRsts;

// Количество соединений, которые присутствуют в системе.

DWORD dwNumConns;

} MIB_TCPSTATS_LH, *PMIB_TCPSTATS_LH;

RtoAlgorithm может быть следующим:

• MIB_TCP_RTO_OTHER — прочее.

• MIB_TCP_RTO_CONSTANT — константное время ожидания.

• MIB_TCP_RTO_RSRE — MIL-STD-1778, см. приложение B.

• MIB_TCP_RTO_VANJ — алгоритм Ван Якобсона.

Управление потоком защищает получателя данных от перегрузки. Но он не защищает сеть, так как абоненты не знают, какой объем канала доступен в момент начала соединения.

Чтобы скорость передачи изменялась соответственно степени загруженности канала, Ван Якобсон и Майкл Дж. Карелс предложили несколько алгоритмов:

• Медленный старт. TCP начинает работать с низкой скоростью. Потом по возможности «разгоняется».

• Предотвращение перегрузки. Если в сети теряются пакеты, это говорит о перегрузке. Скорость понижается.

• Быстрая повторная передача и быстрое восстановление.

Эти алгоритмы сложнее, чем здесь описано, и для их понимания потребуется углубиться в тему.

В структуре PMIB_TCPSTATS2 тип полей числа входящих и исходящих сегментов — DWORD64. А называются эти поля в структуре PMIB_TCPSTATS2 — dw64InSegs и dw64OutSegs.

Функция SetTcpEntry() позволяет разработчику установить состояние указанного TCP-соединения в MIB_TCP_STATE_DELETE_TCB:

#include <iphlpapi.h>

DWORD SetTcpEntry(PMIB_TCPROW pTcpRow);

Параметр pTcpRow указывает на структуру MIB_TCPROW с новой информацией об идентификации TCP-подключения. Также указывается новое состояние для TCP-подключения.

Функция GetPerTcp6ConnectionEStats() возвращает расширенную статистику для TCP-соединения IPv6, а функция SetPerTcp6ConnectionEStats() устанавливает флаги получения статистик чтения и записи для TCP-соединения IPv6.

Эта функция используется для включения или отключения расширенной статистики для TCP-соединения IPv6.

#include <iphlpapi.h>

ULONG GetPerTcp6ConnectionEStats(

PMIB_TCP6ROW Row, TCP_ESTATS_TYPE EstatsType, PUCHAR Rw, ULONG RwVersion,

ULONG RwSize, PUCHAR Ros, ULONG RosVersion, ULONG RosSize, PUCHAR Rod,

ULONG RodVersion, ULONGRodSize

);

ULONG SetPerTcp6ConnectionEStats(

PMIB_TCP6ROW Row, TCP_ESTATS_TYPE EstatsType, PUCHAR Rw, ULONG RwVersion,

ULONG RwSize, ULONG Offset

);

Параметры функций GetPerTcp6ConnectionEStats() и SetPerTcp6ConnectionEStats():

• Row — указатель на структуру MIB_TCP6ROW.

• EstatsType — тип запрашиваемой статистики. Например, TcpConnectionEstatsBandwidth — информация о пропускной способности, которая будет записана в параметр Rw. Таких параметров много, все они описаны в MSDN на странице функции149.

• Rw — буфер для получения информации о чтении и записи.

• Ros — буфер для получения статической информации, доступной только для чтения.

• Rod — буфер для получения динамической информации, доступной только для чтения.

• Offset — смещение в байтах элемента структуры, на который указывает устанавливаемый параметр Rw. Параметр не используется.

Параметры функций *Version указывают версию запрашиваемых данных, а параметры Size — размеры буферов.

Структура MIB_TCP6ROW содержит адреса и состояние подключения:

typedef struct _MIB_TCP6ROW

{

// Состояние TCP-соединения: MIB_TCP_STATE_CLOSED, MIB_TCP_STATE_LISTEN,

// MIB_TCP_STATE_ESTAB и т.д.

MIB_TCP_STATE State;

// Локальный IPv6-адрес, порт, идентификатор области.

IN6_ADDR LocalAddr;

DWORD dwLocalScopeId;

DWORD dwLocalPort;

// Удаленный IPv6-адрес, порт, идентификатор области.

IN6_ADDR RemoteAddr;

DWORD dwRemoteScopeId;

DWORD dwRemotePort;

} MIB_TCP6ROW, *PMIB_TCP6ROW;

Также IP Helper предоставляет функции для получения данных о модуле, к которому привязан контекст сокета:

DWORD GetOwnerModuleFromTcpEntry(

PMIB_TCPROW_OWNER_MODULE pTcpEntry, TCPIP_OWNER_MODULE_INFO_CLASS Class,

PVOID pBuffer, PDWORD pdwSize

);

DWORD GetOwnerModuleFromTcp6Entry(

PMIB_TCP6ROW_OWNER_MODULE pTcpEntry,

TCPIP_OWNER_MODULE_INFO_CLASS Class, PVOID pBuffer, PDWORD pdwSize

);

Здесь мы эти функции описывать не будем, так как они используются достаточно редко.

UDP

Для получения статистики о UDP существует аналогичный набор функций.

Функции типа GetUdpTable() возвращают таблицу UDP:

ULONG GetUdpTable(PMIB_UDPTABLE UdpTable, PULONG SizePointer, bool Order);

ULONG GetUdp6Table(PMIB_UDP6TABLE Udp6Table, PULONG SizePointer, bool Order);

Параметры функций GetUdpTable() и GetUdp6Table():

• UdpTable — указатель на буфер, в который будет записана UDP-таблица.

• SizePointer — размер буфера в байтах. Если размера буфера недостаточно, функция установит параметр равным требуемому количеству байтов в буфере.

• Order — если этот параметр имеет значение «истина», возвращаемая таблица будет отсортирована в порядке возрастания адреса и порта источника.

Структуры для IPv4:

#include <iphlpapi.h>

typedef struct _MIB_UDPROW

{

// IP-адрес конечной точки UDP на локальном узле.

DWORD dwLocalAddr;

// Порт конечной точки UDP на локальном узле.

DWORD dwLocalPort;

} MIB_UDPROW, *PMIB_UDPROW;

typedef struct _MIB_UDPTABLE

{

DWORD dwNumEntries;

MIB_UDPROW table[ANY_SIZE];

} MIB_UDPTABLE, *PMIB_UDPTABLE;

И для IPv6:

typedef struct _MIB_UDP6ROW

{

// IP-адрес конечной точки UDP на локальном узле.

IN6_ADDR dwLocalAddr;

// Идентификатор области IPv6-адреса.

DWORD dwLocalScopeId;

// Локальный порт.

DWORD dwLocalPort;

} MIB_UDP6ROW, *PMIB_UDP6ROW;

Для выборочного получения данных существует такая же функция, как и для TCP:

DWORD GetExtendedUdpTable(

PVOID pUdpTable,

PDWORD pdwSize,

bool bOrder,

ULONG ulAf,

UDP_TABLE_CLASS TableClass,

ULONG Reserved

);

Параметры функции GetExtendedUdpTable():

• pUdpTable — указатель на буфер для таблицы.

• pdwSize — размер буфера. Сюда же будет записан требуемый размер, если переданного недостаточно.

• bOrder — флаг включения упорядочивания записей в таблице.

• ulAf — запрашиваемое семейство адресов: AF_INET или AF_INET6.

• TableClass — тип извлекаемых записей:

• UDP_TABLE_BASIC — структура MIB_UDPTABLE, содержащая все конечные точки UDP локального узла;

• UDP_TABLE_OWNER_PID — MIB_UDPTABLE_OWNER_PID или MIB_UDP6TABLE_OWNER_PID, содержащая все конечные точки UDP;

• UDP_TABLE_OWNER_MODULE — MIB_UDPTABLE_OWNER_MODULE или MIB_UDP6TABLE_OWNER_MODULE.

• Reserved — зарезервировано и должно быть равно нулю.

Функция GetUdpStatistics() и подобные возвращают текущую статистику протокола:

#include <iphlpapi.h>

typedef struct _MIB_UDPSTATS

{

// Количество полученных дейтаграмм.

DWORD dwInDatagrams;

// Количество дейтаграмм, отклоненных из-за некорректного порта.

DWORD dwNoPorts;

// Количество полученных ошибочных дейтаграмм, кроме учтенных в dwNoPorts.

DWORD dwInErrors;

// Количество отправленных дейтаграмм.

DWORD dwOutDatagrams;

// Количество записей в таблице UDP-сокетов, ожидающих дейтаграммы.

DWORD dwNumAddrs;

} MIB_UDPSTATS, *PMIB_UDPSTATS;

ULONG GetUdpStatistics(PMIB_UDPSTATS Stats);

ULONG GetUdpStatisticsEx(PMIB_UDPSTATS Statistics, ULONG Family);

ULONG GetUdpStatisticsEx2(PMIB_UDPSTATS2 Statistics, ULONG Family);

Параметры функций GetUdpStatistics(), GetUdpStatisticsEx(), GetUdpStatisticsEx2():

• Family — запрашиваемое семейство протоколов AF_INET либо AF_INET6;

• Stats — указатель на структуру, которая получает статистику UDP.

В структуре MIB_UDPSTATS2 счетчики принятых и отправленных дейтаграмм являются 64-битными.

Резюме

Вспомогательный API IP Helper предоставляет интерфейс для механизмов уведомления приложений об изменении настроек конфигурации сети. Поэтому с помощью IP Helper легко отслеживать, например, изменение набора IP-адресов интерфейсов.

Этот API позволяет работать с DHCP, что дает возможность обновлять и удалять выделенные IP-адреса, выданные интерфейсу.

Кроме того, IP Helper позволяет собирать различную статистику, например определять тип подключения и стоимость проходящих через него данных. С его помощью очень просто получить информацию о сетевых интерфейсах, включая IP-адреса, MAC-адреса, статус соединения и другие параметры. Это позволяет программам динамически адаптировать свое поведение в зависимости от сетевого окружения.

IP Helper предоставляет функции, которые помогают в диагностике сетевых проблем. Существует API для работы с протоколами маршрутизации, включая добавление и удаление маршрутов, получение информации о текущих маршрутах и определение наилучшего пути для доставки пакетов данных. Таким образом, в составе этого API представлено множество разнородных функций, так или иначе связанных с разработкой сетевых приложений. Большинство этих функций не переносимо. Разумно использовать библиотеки, чтобы нивелировать эти различия.

В целом API IP Helper является мощным инструментом для разработчиков сетевых приложений под Windows, позволяя им эффективно управлять сетью и получать необходимую информацию о сетевом окружении.

Вопросы и задания

1. Что такое API IP Helper и для чего он используется?

2. Поддерживает ли IP Helper IPv6?

3. Какую библиотеку необходимо подключить для использования IP Helper в проекте?

4. Как с помощью IP Helper API получить адреса интерфейса?

5. Как примерно оценить стоимость данных, проходящих через интерфейс?

6. Каким образом можно обновить адрес, полученный интерфейсом по DHCP?

7. Какие параметры можно установить с помощью функции SetIpStatistics()?

8. Какими способами можно установить TTL интерфейса?

9. Какие возможности предоставляет IP Helper API для мониторинга и отладки сетевых соединений?

10. Почему для работы по ICMP в ОС Windows существует отдельный API?

11. Как, используя IP Helper API, отправить эхо-запрос ICMP и принять ответ?

12. Зачем и в каких случаях используется функция IcmpParseReplies()?

13. Как получить ARP-таблицу?

14. Если адрес какого-либо сетевого интерфейса изменился, как узнать об этом, не прибегая к опросу?

15. Для обнаружения изменения каких параметров сетевого интерфейса можно использовать IP Helper API?

16. Какой механизм использует IP Helper для уведомления приложений об изменении настроек конфигурации сети?

17. Как получить лучший маршрут до сервера?

18. Какую статистику IP Helper позволяет получить для TCP? А для UDP? Какие функции используются для получения этой статистики?

19. Используя функции IP Helper API, измените IP-адрес сетевого интерфейса.

20. Напишите программу, которая использует NotifyAddrChange() для отслеживания изменений в адресах интерфейсов и выводит информацию о добавленных или удаленных адресах.

148 https://learn.microsoft.com/ru-ru/windows/win32/api/Iphlpapi/nf-iphlpapi-getextendedtcptable

149 https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getpertcp6connectionestats

Глава 20. WinInet, WinHTTP, MFC, WFP, WSL

Иногда какие-то вещи нам не удается предугадать. Например, когда появился интернет, он занимал у нас пятое-шестое место по важности.

Билл Гейтс, выступление в Вашингтонском университете, 1998

Введение

В этой главе мы кратко рассмотрим обертки и высокоуровневые примитивы, которые можно использовать при разработке сетевых Windows-приложений. Это библиотеки WinINet и WinHTTP, предоставляющие высокоуровневые функции и набор классов MFC. Они не требуют от разработчиков глубоких знаний сокетов, TCP/IP и деталей конкретных сетевых протоколов.

Мы также кратко ознакомимся с универсальной технологией фильтрации сети Windows — WFP, на которой основан межсетевой экран в ОС Windows.

Технология дает возможность фильтровать и изменять сетевые данные, а также перенаправлять их в другое место назначения.

В завершение главы мы расскажем о подсистеме WSL, позволяющей выполнять сетевые приложения Linux в ОС Windows без перекомпиляции.

API высокого уровня: Windows Internet и Windows HTTP

Мы рассмотрели функции низкого уровня, но часто прикладному разработчику ими пользоваться не обязательно. За него уже все написали системные программисты Microsoft.

WinINet был разработан как клиентская платформа для классических приложений, выполняющих обмен по HTTP. Он может использовать GUI, например, для вывода сообщений об ошибках.

Это не новый набор функций, они поддерживаются еще с поздних версий Windows 98, поэтому об их использовании накоплено достаточно информации.

WinINet API позволяет приложениям получать доступ к протоколам интернета для выполнения следующих действий:

• загрузка HTML-страниц;

• отправка FTP-запросов на загрузку файлов или получение списков каталогов. Типичным запросом является анонимный вход в систему для загрузки файла;

• использование системы меню Gopher для доступа к ресурсам в интернете.

WinINet абстрагирует эти протоколы в высокоуровневый интерфейс.

Функции WinInet можно вызывать напрямую или использовать через классы MFC, описанные далее.

Функция InternetOpen() служит для получения дескриптора, который будет в дальнейшем использован в вызовах функций WinInet:

#include <wininet.h>

HINTERNET InternetOpen(LPCSTR lpszAgent, DWORD dwAccessType, LPCSTR lpszProxy,

LPCSTR lpszProxyBypass, DWORD dwFlags);

Параметры функции InternetOpen():

• lpszAgent — указатель на строку, содержащую значение HTTP-заголовка User-Agent.

• dwAccessType — тип доступа. Может принимать следующие значения:

• INTERNET_OPEN_TYPE_DIRECT — выполнить разрешение имен узлов локально.

• INTERNET_OPEN_TYPE_PRECONFIG — получить прокси-сервер или конфигурацию без прокси из реестра.

• INTERNET_OPEN_TYPE_PRECONFIG_WITH_NO_AUTOPROXY — то же, что и INTERNET_OPEN_TYPE_PRECONFIG, а также запрет использования загрузочного файла Microsoft JScript или файла настройки интернета.

• INTERNET_OPEN_TYPE_PROXY — отправлять запросы прокси-серверу, если не указан список непроксируемых узлов или узел не в этом списке.

• lpszProxy — необязательный указатель на строку, в которой указывается прокси-сервер.

• lpszProxyBypass — необязательный указатель на строку, в которой указываются серверы, запросы к которым проксировать не требуется.

• dwFlags — параметры работы. Может принимать значения:

• INTERNET_FLAG_ASYNC — выполнять только асинхронные запросы к дескрипторам;

• INTERNET_FLAG_FROM_CACHE, INTERNET_FLAG_OFFLINE — не выполнять сетевые запросы. Результат возвращается из кэша; если запрошенного элемента нет в кэше, возникает ошибка.

Функция вернет дескриптор, который может быть использован в дальнейших вызовах.

Например, следующая функция откроет FTP- или HTTP-ресурс по URL:

HINTERNET InternetOpenUrl(HINTERNET hInternet, LPCSTR lpszUrl,

LPCSTR lpszHeaders, DWORD dwHeadersLength,

DWORD dwFlags, DWORD_PTR dwContext);

Параметры функции InternetOpenUrl():

• hInternet — дескриптор текущего сеанса интернета.

• lpszUrl — URL.

• lpszHeaders — указатель на строку, которая содержит HTTP-заголовки для отправки на сервер.

• dwHeadersLength — размер дополнительных заголовков.

• dwFlags — параметры работы. Влияют на переиспользование соединения, HTTP-редиректы, сертификаты, аутентификацию и т.п.

• dwContext — указатель на переменную, значение которой будет передаваться в функции обратного вызова.

Флагов достаточно много, подробное их описание см. в MSDN150. Мы же разделим флаги по категориям.

Флаги, управляющие кэшированием:

• INTERNET_FLAG_HYPERLINK — принудительно выполнить загрузку, если при определении необходимости загрузки из сети сервер не вернул заголовки Expires и LastModified.

• INTERNET_FLAG_RELOAD — принудительно загрузить запрошенный объект с сервера, а не из кэша.

• INTERNET_FLAG_NEED_FILE — создать временный файл, если загружаемый не может быть кэширован.

• INTERNET_FLAG_RESYNCHRONIZE — перезагрузить HTTP-ресурс, если он был изменен с момента последней загрузки. Все FTP-ресурсы перезагружаются.

• INTERNET_FLAG_NO_CACHE_WRITE — не добавлять возвращенный объект в кэш.

• INTERNET_FLAG_PRAGMA_NOCACHE — принудительно разрешить запрос через исходный сервер, даже если прокси-сервер предоставляет копию.

Флаги перенаправлений и управления соединением:

• INTERNET_FLAG_NO_AUTO_REDIRECT — не обрабатывать перенаправление HttpSendRequest автоматически.

• INTERNET_FLAG_IGNORE_REDIRECT_TO_HTTP — отключить перенаправление URL-адресов с HTTPS на HTTP.

• INTERNET_FLAG_IGNORE_REDIRECT_TO_HTTPS — отключить перенаправление URL-адресов с HTTP на HTTPS.

• INTERNET_FLAG_KEEP_CONNECTION — использовать keep-alive — поддержание активности, если такая возможность доступна. Флаг необходим для проверки подлинности в сетях Microsoft: MSN, NTLM и других типов.

Флаги безопасности:

• INTERNET_FLAG_NO_AUTH — не пытаться выполнить аутентификацию автоматически.

• INTERNET_FLAG_IGNORE_CERT_CN_INVALID — отключить проверку сертификатов Secure Sockets Layer/Private Communications Technology, SSL/PCT, возвращаемых сервером, по имени узла в запросе.

• INTERNET_FLAG_IGNORE_CERT_DATE_INVALID — отключить проверку сроков действия сертификатов SSL/PCT.

• ИНТЕРНЕТ_FLAG_SECURE — использовать безопасные транзакции, то есть протокол SSL/PCT. Имеет смысл только в HTTP-запросах.

Флаги управления cookies:

• INTERNET_FLAG_NO_COOKIES — не добавлять заголовки к запросам файлов cookie автоматически и не добавлять возвращенные файлы cookie в базу данных.

• INTERNET_FLAG_NO_UI — отключить диалоговое окно cookie.

Флаги FTP:

• INTERNET_FLAG_EXISTING_CONNECT — попытаться использовать существующий объект InternetConnect, если он имеет те же атрибуты, которые необходимы для выполнения запроса. Данный флаг полезен только при операциях FTP, чтобы выполнять несколько операций в течение одного сеанса. WinINet API кэширует дескриптор соединения для каждого дескриптора HINTERNET, созданного InternetOpen().

• INTERNET_FLAG_RAW_DATA — вернуть структуру WIN32_FIND_DATA при получении информации о FTP-каталоге. Если флаг не указан или вызов был выполнен через прокси-сервер, InternetOpenUrl() вернет HTML-представление каталога. Ранние версии Windows также возвращают структуру GOPHER_FIND_DATA при получении информации о каталоге Gopher.

• INTERNET_FLAG_PASSIVE — использовать пассивный режим FTP.

Функция вернет дескриптор, который может использоваться, чтобы прочитать данные. Сделать это возможно, используя следующую функцию:

bool InternetReadFile(HINTERNET hFile, LPVOID lpBuffer,

DWORD dwNumberOfBytesToRead,

LPDWORD lpdwNumberOfBytesRead);

Параметры функции InternetReadFile():

• hFile — дескриптор, возвращенный InternetOpenUrl(), FtpOpenFile() или HttpOpenRequest().

• lpBuffer — указатель на буфер, который получит данные.

• dwNumberOfBytesToRead — количество считываемых байтов.

• lpdwNumberOfBytesRead — сюда будет записано количество прочитанных байтов.

Если данные получены, функция вернет истину.

Когда требуется открыть сессию с сервером HTTP или FTP, можно использовать функцию InternetConnect():

HINTERNET InternetConnect(HINTERNET hInternet, LPCSTR lpszServerName,

INTERNET_PORT nServerPort, LPCSTR lpszUserName,

LPCSTR lpszPassword, DWORD dwService,

DWORD dwFlags, DWORD_PTR dwContext);

Параметры функции InternetConnect():

• lpszServerName — указатель на строку, в которой передается имя узла интернет-сервера.

• nServerPort — порт TCP/IP на сервере.

• lpszUserName — указатель на строку, в которой передается имя пользователя для входа.

• lpszPassword — указатель на строку, в которой передается пароль пользователя для входа.

• dwService — тип службы доступа:

• INTERNET_SERVICE_HTTP — для HTTP;

• INTERNET_SERVICE_FTP — для FTP;

• INTERNET_SERVICE_GOPHER — для Gopher.

• dwFlags — флаги. Для FTP определен единственный флаг — INTERNET_FLAG_PASSIVE, открывающий сессию в пассивном режиме.

• dwContext — указатель на переменную, содержащую значение, определенное приложением, которое передается в обратные вызовы.

После завершения работы все дескрипторы необходимо закрыть. Для этого используется следующая функция:

bool InternetCloseHandle(HINTERNET hInternet);

Как взаимодействуют функции Internet API, показано на рис. 20.1, где не только изображена структура функций, но и отмечено, в каком порядке эти функции необходимо вызывать.

Использовать данный API очень просто:

int main()

{

const auto internet_handle = InternetOpen(TEXT("Mozilla/5.12345"),

INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0);

if (!internet_handle)

{

std::cerr

<< "InternetOpen failed with code " << GetLastError()

<< std::endl;

return EXIT_FAILURE;

}

Рис. 20.1. WinINet-функции для работы с FTP и HTTP

// Открыть ресурс.

auto url_handle = InternetOpenUrl(internet_handle,

TEXT("http://artiomsoft.ru"), 0, 0,

INTERNET_FLAG_RAW_DATA, 0);

if (!url_handle)

{

std::cerr

<< "InternetOpenUrl failed with code " << GetLastError()

<< std::endl;

InternetCloseHandle(internet_handle);

return EXIT_FAILURE;

}

std::array<TCHAR, 1024> buf;

DWORD read_bytes = 0;

// Прочитать данные.

while (InternetReadFile(url_handle, buf.data(),

static_cast<DWORD>(buf.size()), &read_bytes))

{

if (!read_bytes) break;

std::ostream_iterator<TCHAR> out_it(std::cout, "");

std::copy(buf.begin(), buf.begin() + read_bytes, out_it);

}

// Закрыть дескриптор ресурса.

InternetCloseHandle(url_handle);

// Закрыть дескриптор интернета.

InternetCloseHandle(internet_handle);

return EXIT_SUCCESS;

}

Вызов приведет к загрузке индексной страницы HTTP-ресурса:

D:\build\bin> b01-ch20-windows-internet.exe

<!DOCTYPE HTML>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

<title>Artiomsoft</title>

...

Вместо InternetOpenUrl() можно использовать более специфичные функции, такие как HttpOpenRequest() и FtpOpenFile():

#include <wininet.h>

HINTERNET FtpOpenFile(HINTERNET hConnect, LPCSTR lpszFileName,

DWORD dwAccess, DWORD dwFlags, DWORD_PTR dwContext);

HINTERNET HttpOpenRequest(HINTERNET hConnect, LPCSTR lpszVerb,

LPCSTR lpszObjectName, LPCSTR lpszVersion, LPCSTR lpszReferrer,

LPCSTR *lplpszAcceptTypes, DWORD dwFlags, DWORD_PTR dwContext);

Также можно отправить HTTP-запросы, вызвав функцию HttpSendRequest():

bool HttpSendRequest(HINTERNET hRequest, LPCSTR lpszHeaders,

DWORD dwHeadersLength, LPVOID lpOptional, DWORD dwOptionalLength);

Параметры функции HttpSendRequest():

• hRequest — дескриптор, возвращаемый вызовом функции HttpOpenRequest().

• lpszHeaders — указатель на строку с заголовками для отправки HTTP-серверу.

• dwHeadersLength — размер дополнительных заголовков.

• lpOptional — указатель на необязательные данные, отправляемые сразу после заголовков запроса. Этот параметр обычно используется для HTTP-запросов PUT и POST.

• dwOptionalLength — размер необязательных данных в байтах.

В случае успешной отправки запроса функция вернет истину.

WinHTTP — службы HTTP Microsoft Windows, предоставляющие высокоуровневый интерфейс для серверных и неинтерактивных приложений, работающих по HTTP/1.1 и HTTP/2 и, начиная с Windows 8, по протоколу WebSocket. Он используется в системных службах и клиентских приложениях на основе HTTP, но не поддерживает FTP, cookies, кэширование, автоматический ввод учетных данных посредством диалоговых окон, совместимость с браузером и т.п.

Структура и взаимодействие функций WinHTTP API показаны на рис. 20.2.

Рис. 20.2. WinHTTP-функции

WinHTTP API более, чем WinINet API, и построен так, чтобы быть похожим на WinINet. Функции одного API во многом отображаются на функции другого.

Например, аналог функции HttpOpenRequest() в WinHTTP — функция WinHttpOpenRequest(), функциям HttpSendRequest() и HttpSendRequestEx() соответствует функция WinHttpSendRequest() и т.д.

Хотя для некоторых функций эквиваленты отсутствуют, сложности при использовании API возникнуть не должно. А для того, чтобы облегчить перенос с WinINet на WinHTTP, Microsoft предоставляет руководство по переносу приложений с WinINet в WinHTTP151.

Сокеты в MFC

Microsoft Foundation Classes — это набор C++-классов и функций, предоставляемых Microsoft для разработки Windows-приложений. Для наглядности он представлен на рис. 20.3.

Рис. 20.3. Набор C++-классов и функций MFC

MFC является частью пакета разработки приложений Microsoft Visual Studio и в основном используется для создания графических интерфейсов.

Однако в MFC существует много классов, позволяющих работать с различными WinAPI в рамках объектного подхода, в частности, предназначенных для сетевой подсистемы.

Классы поддерживают две модели взаимодействия:

• Синхронную, или блокирующую. Данная модель реализована в классе CSocket.

• Асинхронную. Данная модель реализована в классе CAsyncSocket.

Класс CAsyncSocket является базовым для класса CSocket. Он обеспечивает абстракцию более высокого уровня для работы через объект MFC CArchive. Это необходимо для синхронной работы с экземплярами класса CArchive, который выполняет сериализацию и десериализацию. Данные концепции мы рассмотрим в книге 3.

CSocket наследует многие функции-члены от CAsyncSocket, которые инкапсулируют API-интерфейсы Windows Sockets, а также обеспечивает блокировку и фоновую обработку сообщений Windows.

Обычно при работе создается экземпляр класса CSocket с параметрами по умолчанию для клиента, а для сервера указывается порт в вызове Create(). Затем клиенту необходимо вызвать CAsyncSocket::Connect() для подключения к серверу. Если этот сокет серверный, нужно вызвать CAsyncSocket::Listen(), чтобы ожидать клиент, и CAsyncSocket::Accept(), чтобы принять запрос на подключение.

Экземпляр класса CArchive взаимодействует с экземпляром класса CSocketFile, который вместо обмена данными с файлом выполняет обмен через CSocket, как это показано на рис. 20.4.

Рис. 20.4. Обмен данными через CSocket

Класс CArchive управляет буфером. Когда буфер отправителя заполнен, связанный с ним объект CFile записывает содержимое буфера.

Опустошение буфера архива, подключенного к сокету, эквивалентно отправке сообщения. Когда буфер принимающего абонента заполняется, объект CFile прекращает чтение до тех пор, пока буфер снова не станет доступен.

Класс CSocketFile является наследником от CFile, но не поддерживает методы CFile для определения длины, позиционирования и блокировки.

Неподдерживаемые методы CFile определены в CSocketFile, но они генерируют исключение CNotSupportedException.

Экземпляр класса CSocketFile вызывает методы объекта класса CSocket для отправки или получения данных. Разработчику достаточно создать сокет, файл и архив, чтобы начать обмен данными, добавляя их в «архив» и извлекая из него.

Хотя в классе CSocket реализован синхронный сокет, экземпляры этого класса могут получать асинхронные уведомления. Синхронными являются операции получения и отправки данных. Пока синхронная операция не будет завершена, сокет не будет получать асинхронные уведомления.

Для обработки асинхронных уведомлений можно использовать следующие методы, переопределяемые в классе-наследнике:

• OnReceive() — в буфере есть данные для чтения;

• OnSend() — можно отправлять данные, вызвав Send();

• OnAccept() — прослушивающий сокет может принимать ожидающие запросы на подключение, вызвав Accept();

• OnConnect() — уведомление об успешном или ошибочном завершении попытки подключения;

• OnClose() — сокет закрыт.

Помимо сокетов MFC поддерживает классы-расширения WinINet, которые упрощают получение данных по HTTP и FTP без использования WinSock и предоставляют следующие возможности:

• буферизованный ввод-вывод;

• типобезопасные дескрипторы для данных;

• параметры по умолчанию для функций;

• обработку исключений для распространенных ошибок;

• автоматическое закрытие хэндлов и соединений.

Классы MFC WinINet предоставляют те же функции, что и CStdioFile, для данных, передаваемых через интернет.

Для создания подключения к FTP-серверу в MFC достаточно сделать один вызов CInternetSession::GetFTPConnection().

Еще одно из «сетевых» расширений MFC реализует доступ к MAPI152 — интерфейсу для создания, обработки, передачи и хранения почтовых сообщений.

WFP: платформа фильтрации Windows

Windows Filtering Platform, или WFP153, — это универсальная платформа для контроля сетевых взаимодействий. Она весьма напоминает BPF 2 в Linux и показана на рис. 20.5.

Платформа фильтрации охватывает все основные уровни, начиная с транспортного и заканчивая канальным, и предоставляет разработчикам множество возможностей для фильтрации и изменения сетевых данных перед их получением точкой назначения.

WFP появилась в Windows Vista и с тех пор постоянно развивается и обновляется.

На данный момент существует возможность фильтрации на уровне Ethernet, а также перенаправления как исходящих, так и входящих соединений, в том числе многократное перенаправление соединения с одного прокси-сервера на другой.

Платформа состоит из подсистемы фильтрации, драйверов Callout, оболочек совместимости, базового модуля фильтрации и API-вызовов.

Рис. 20.5. Архитектура WFP154

Подсистема фильтрации

Filtering Engine — базовая многоуровневая инфраструктура фильтрации.

WFP работает с модулями, которые предоставляют набор функций для фильтрации трафика; в терминологии WFP этот набор функций называют callout, или внешними вызовами.

Callout проводят инспекцию пакетов трафика:

• classifyFn — классифицирующая функция. Определяет, запретить или разрешить пакет или соединение, а также может возвращать состояния: продолжить обработку, запросить больше данных, прервать соединение.

• notifyFn — вызывается при создании или удалении фильтра.

• flowDeleteFn — вызывается при удалении потока обработки. В функции обычно выполняется очистка.

Инфраструктура фильтрации решает следующие задачи:

• Фильтрует сетевой трафик на любом уровне по любым полям данных, которые может предоставить оболочка.

• Реализует фильтры путем вызова функций внешних классификаторов в процессе классификации.

• Возвращает состояние «Разрешить» или «Блокировать» вызвавшей его оболочке.

• Обеспечивает арбитраж между различными источниками политик. Например, определяет приоритет, если приложение настроено получать защищенный трафик, но брандмауэр настроен для предотвращения этого.

Фильтры — правила, указывающие, в каких случаях будет вызываться нужный callout. Фильтры могут объединяться по какому-то признаку. Такие объединения называют слоем.

Драйверы Callout

Драйверы добавляют пользовательские функции в механизм фильтрации пространства ядра и позволяют выполнять глубокую проверку и модификацию пакетов и потоков. После того как драйвер добавил функции в механизм фильтрации, приложение или любой драйвер могут добавить фильтры в процесс фильтрации.

Разработчик указывает функцию обратного вызова при регистрации правила фильтрации. Когда фильтр срабатывает, система вызывает переданный обработчик.

С некоторыми устаревшими сетевыми драйверами, которые не поддерживают последнюю версию API вызовов WFP, подсистема взаимодействует, используя редиректоры вызовов функций.

Редиректоры WFP — это фрагменты кода, которые перехватывают и перенаправляют вызовы к API устаревшего драйвера к более новому API WFP. Это позволяет драйверу работать с последней версией платформы, не требуя значительных изменений в коде. Использование этой прослойки помогает обеспечить обратную совместимость и более плавный переход к новым технологиям.

Shims — оболочки совместимости

Оболочки — это компоненты режима ядра, которые находятся между сетевым стеком и подсистемой фильтрации. Оболочки раскрывают внутреннюю структуру пакета, как набор свойств на разных уровнях. По этим свойствам пакета может происходить фильтрация.

Ниже приведен список доступных оболочек:

• Application Layer Enforcement — оболочка для применения политик на уровне приложений.

• Модуль транспортного уровня.

• Модуль сетевого уровня.

• Модуль получения ошибок протокола ICMP.

• Работа с потоком.

• Оболочка для вызова RPС (удаленный вызов процедур).

Базовый модуль фильтрации

Base Filtering Engine, или BFE, — служба, управляющая работой платформы фильтрации Windows. Она делает следующее:

• Получает фильтры и параметры конфигурации платформы.

• Применяет модель безопасности платформы. Например, локальный администратор может добавлять фильтры, но другие пользователи могут их только просматривать.

• Настраивает модули, например, отправляя политики IPsec в модули ключей IKE/AuthIP или фильтры в модули фильтрации.

• Сообщает текущее состояние системы, в том числе статистику.

Работа с WFP

Первое, что необходимо сделать для работы с WFP, — открыть сеанс. Для этого вызывается функция FwpmEngineOpen0():

#include <fwpmu.h>

typedef struct FWPM_DISPLAY_DATA0_

{

// Опциональное имя.

wchar_t *name;

// Описание.

wchar_t *description;

} FWPM_DISPLAY_DATA0;

// Состояние, связанное с сеансом клиента.

typedef struct FWPM_SESSION0_

{

// Уникальный идентификатор сеанса.

GUID sessionKey;

// Аннотация сессии в человекочитаемом виде.

FWPM_DISPLAY_DATA0 displayData;

// Флаги. FWPM_SESSION_FLAG_DYNAMIC включает автоудаление объектов сессии.

UINT32 flags;

// Время ожидания транзакции.

UINT32 txnWaitTimeoutInMSec;

DWORD processId;

SID *sid;

wchar_t *username;

// Истина для клиента ядра.

bool kernelMode;

} FWPM_SESSION0;

DWORD FwpmEngineOpen0(const wchar_t *serverName, UINT32 authnService,

SEC_WINNT_AUTH_IDENTITY_W *authIdentity, const FWPM_SESSION0 *session,

HANDLE *engineHandle);

Параметры функции FwpmEngineOpen0():

• engineHandle — дескриптор открытого сеанса в подсистеме фильтрации.

• serverName — строка, содержащая DNS-имя системы, в которой открыт сеанс фильтрации. В случае драйверов callout это значение нулевое.

• authnService — указывает используемую службу проверки подлинности:

• RPC_C_AUTHN_WINNT — NT-аутентификация.

• RPC_C_AUTHN_DEFAULT — аутентификация по умолчанию.

• authIdentity — учетные данные для аутентификации и авторизации для доступа к подсистеме фильтрации. Необязательный параметр.

• session — параметры открываемого сеанса. Необязательный параметр.

Функция возвращает STATUS_SUCCESS в случае успеха или код ошибки. Возвращаемое значение большинства других функций аналогичное.

Изменения производятся в транзакциях, которые начинает функция FwpmTransactionBegin0():

DWORD FwpmTransactionBegin0(HANDLE engineHandle, UINT32 flags);

Параметры функции FwpmTransactionBegin0():

• engineHandle — дескриптор открытого сеанса.

• flags — флаги транзакции:

• 0 — транзакция для чтения и записи;

• FWPM_TXN_READ_ONLY — транзакция только для чтения.

Далее можно, например, создать новый callout:

typedef struct FWPS_CALLOUT0_

{

// Уникальный идентификатор callout.

GUID calloutKey;

UINT32 flags;

// Указатель на классифицирующую функцию

FWPS_CALLOUT_CLASSIFY_FN0 classifyFn;

// Указатель на функцию, которая вызывается при добавлении

// или удалении фильтра.

FWPS_CALLOUT_NOTIFY_FN0 notifyFn;

// Указатель на функцию, которая вызывается при закрытии потока.

FWPS_CALLOUT_FLOW_DELETE_NOTIFY_FN0 flowDeleteFn;

} FWPM_CALLOUT0;

NTSTATUS FwpsCalloutRegister0(

void *deviceObject, const FWPS_CALLOUT0 *callout, UINT32 *calloutId

);

Параметры функции FwpsCalloutRegister0():

• deviceObject — указатель на объект устройства, который ранее был создан драйвером callout.

• callout — идентификатор callout, флаги и указатели на функции:

• classifyFn — классификации;

• notifyFn — уведомления о добавлении/удалении фильтра;

• flowDeleteFn — уведомления о закрытии обрабатываемого потока.

• calloutId — указатель на переменную, которая получает идентификатор callout в движке фильтра.

Первые две функции в структуре являются обязательными, последняя нужна лишь в случае, если необходимо отслеживать сами пакеты, а не только соединения. По сути, эта функция регистрирует обработчики в движке фильтрации.

Зарегистрированный callout необходимо добавить в систему, привязав его к определенному уровню:

typedef struct FWPM_DISPLAY_DATA0_

{

// Опциональное имя.

wchar_t *name;

// Описание.

wchar_t *description;

} FWPM_DISPLAY_DATA0;

typedef struct FWPM_CALLOUT0_

{

// Уникальный идентификатор callout.

GUID calloutKey;

// Удобочитаемые аннотации, связанные с callout.

FWPM_DISPLAY_DATA0 displayData;

// Флаги. Например, сохранять ли callout между перезагрузками.

UINT32 flags;

// Необязательный GUID ассоциированного провайдера.

GUID *providerKey;

// Произвольные данные, передаваемые провайдеру, если тот задан.

FWP_BYTE_BLOB providerData;

// GUID уровня, связанного с callout.

// Пример: FWPM_LAYER_INBOUND_IPPACKET_V4

GUID applicableLayer;

// Идентификатор, возвращенный функцией регистрации.

UINT32 calloutId;

} FWPM_CALLOUT0;

DWORD FwpmCalloutAdd0(

HANDLE engineHandle, const FWPM_CALLOUT0 *callout,

PSECURITY_DESCRIPTOR sd, UINT32 *id

);

Параметры функции FwpmCalloutAdd0():

• engineHandle — дескриптор открытого сеанса в подсистеме фильтрации.

• callout — описатель callout.

• sd — необязательный дескриптор безопасности callout.

• id — идентификатор callout.

Если callout успешно добавлен в систему, необходимо указать, при каких условиях он будет вызываться, то есть создать фильтр, для чего служит следующая функция:

DWORD FwpmFilterAdd0(HANDLE engineHandle,

const FWPM_FILTER0 *filter,

PSECURITY_DESCRIPTOR sd, UINT64 *id);

Параметры функции FwpmFilterAdd0():

• engineHandle — дескриптор открытого сеанса в подсистеме фильтрации.

• filter — добавляемый объект фильтра.

• sd — сведения о безопасности, связанные с фильтром; необязательный параметр.

• id — идентификатор среды выполнения для фильтра; необязательный параметр.

Фильтр описывается следующей структурой:

typedef struct FWPM_FILTER0_

{

// Уникальный идентификатор фильтра.

GUID filterKey;

// Данные человекочитаемого описания фильтра.

FWPM_DISPLAY_DATA0 displayData;

// Флаги, например флаг персистентности фильтра.

UINT32 flags;

// Необязательный идентификатор провайдера политики для фильтра.

GUID *providerKey;

// Данные, отправляемые провайдеру.

FWP_BYTE_BLOB providerData;

// Идентификатор уровня, к которому подключается фильтр.

GUID layerKey;

// Идентификатор подуровня, например FWPM_SUBLAYER_IPSEC_TUNNEL.

GUID subLayerKey;

// Приоритет фильтра. Чем выше, тем больше.

FWP_VALUE0 weight;

// Количество структур filterCondition в массиве.

UINT32 numFilterConditions;

// Условия фильтра, каждое из которых должно быть выполнено.

FWPM_FILTER_CONDITION0 *filterCondition;

// Действие, выполняемое фильтром.

FWPM_ACTION0 action;

// Контекст провайдера, зависит от флага

// FWPM_FILTER_FLAG_HAS_PROVIDER_CONTEXT в контекстной информации

// провайдера.

union

{

UINT64 rawContext;

GUID providerContextKey;

};

GUID *reserved;

// LUID, идентифицирующий фильтр.

UINT64 filterId;

// Эффективный вес провайдера.

FWP_VALUE0 effectiveWeight;

} FWPM_FILTER0;

Фильтр активируется, если все условия соблюдены. В этом случае, если, например, в типе действия указано FWP_ACTION_CALLOUT_UNKNOWN, то есть вызывать callout, который может разрешить или заблокировать пакет, может быть вызван соответствующий зарегистрированный обработчик из callout.

Структуры условия и действия:

// Условие фильтрации.

typedef struct FWPM_FILTER_CONDITION0_

{

// Значения какого типа будут содержаться в структуре FWP_CONDITION_VALUE.

GUID fieldKey;

// Каким будет сравнение. Например, FWP_MATCH_GREATER.

FWP_MATCH_TYPE matchType;

// Значение, с которым будет сравниваться значение, полученное фильтром.

// Содержит тип и объединение, которое и представляет значение.

FWP_CONDITION_VALUE0 conditionValue;

} FWPM_FILTER_CONDITION0;

// Действие, выполняемое фильтром.

typedef struct FWPM_ACTION0_

{

// Тип действия: FWP_ACTION_BLOCK, FWP_ACTION_PERMIT,

// FWP_ACTION_CALLOUT_TERMINATING, FWP_ACTION_CALLOUT_INSPECTION и т.п.

FWP_ACTION_TYPE type;

union

{

// Произвольный GUID, выданный провайдером политики.

GUID filterType;

// GUID callout.

GUID calloutKey;

};

} FWPM_ACTION0;

И наконец, функция FwpmTransactionCommit0()подтвердит изменения, сделанные в рамках транзакции:

DWORD FwpmTransactionCommit0(HANDLE engineHandle);

Параметр engineHandle — дескриптор открытого сеанса в подсистеме фильтрации.

Функций в WFP достаточно много. Например, существует функция для создания подписки, которая будет вызывать обработчик в случае прихода уведомления. Функции по значению похожи на перечисленные выше, но имеют другой набор аргументов. В этом разделе мы провели только краткий обзор данной системы и поэтому подробно не рассматриваем все API и возможности.

Немного о WSL

Windows Subsystem for Linux, или WSL, — подсистема Windows, которая позволяет запускать инструменты командной строки Linux, такие как Bash-скрипты и файлы ELF64, в ОС Windows без изменений.

Существует возможность установить X-сервер и запускать под WSL графические приложения.

Это уровень совместимости для запуска среды, которая ведет себя так же, как Linux. WSL позиционируется как инструмент для разработчиков и системных администраторов. Используя ее, можно установить различные дистрибутивы Linux и получить доступ к терминалу, файловой и сетевой подсистемам Linux без использования виртуальных машин. Также можно запускать Windows-приложения из Linux и, наоборот, обмениваться файлами и данными по сети между Windows и Linux, использовать одинаковые учетные записи пользователей и настройки.

WSL управляется через одноименную утилиту командной строки. Еще одна утилита, которую можно использовать для управления WSL, — wslconfig.

Для первоначальной установки WSL в режиме администратора необходимо запустить следующую команду:

C:\> wsl --install

WSL можно установить через PowerShell или просто включив компонент в GUI инсталлятора, как показано на рис. 20.6.

Дистрибутив Linux можно указать сразу при установке, например:

C:\> wsl --install -d Debian

Или выбрать позже из списка доступных в Microsoft Store:

C:\> wsl --list --online

...

NAME            FRIENDLY NAME

* Ubuntu          Ubuntu

Debian          Debian GNU/Linux

kali-linux      Kali Linux Rolling

openSUSE-42     openSUSE Leap 42

SLES-12         SUSE Linux Enterprise Server v12

Ubuntu-16.04    Ubuntu 16.04 LTS

Ubuntu-18.04    Ubuntu 18.04 LTS

Ubuntu-20.04    Ubuntu 20.04 LTS

Рис. 20.6. Включение WSL

Установка собственного дистрибутива также поддерживается. Он должен представлять собой пакет AppX в формате MSIX155.

Полный список команд WSL приведен в разделе MSDN «Basic commands for WSL»156, а работа с WSL подробно описана в руководстве от Microsoft в MSDN157.

WSL 1 и WSL 2

Существуют версии WSL 1 и WSL 2, переключаться между которыми можно, используя команду --set-version утилиты wsl.

Первая версия не поддерживает запуск полного ядра Linux, некоторые системные вызовы и IPv6.

WSL 1 предоставляет сетевой API Linux, а код стека выполняется в режиме ядра внутри драйвера подсистемы Linux.

Схема работы сети в WSL 1 изображена на рис. 20.7.

Рис. 20.7. Схема сети в WSL 1

Драйвер работает поверх API ядра — WinSock Kernel или WSK. В основном сетевые драйверы WSL транслируют вызовы BSD-сокетов в вызовы WSK и выполняются сетевым стеком ОС Windows.

Версия 2, показанная на рис. 20.8, запускает ядро Linux в управляемой виртуальной машине, обеспечивая более полную реализацию поддержки Linux и возможность запуска большего количества приложений.

Рис. 20.8. Архитектура WSL 2

Имя узла запущенной копии Linux берется из имени PC в Windows, и его нельзя изменить: содержимое файла /etc/hostname или вызов команды hostnamectl set-hostname не учитываются.

В отличие от первой версии, в WSL 2 сеть работает через виртуальный свитч Hyper-V. Хостовая система Windows является для гостевого Linux шлюзом по умолчанию и сервером DNS. Поэтому ее адрес можно получить, используя разные способы, например команду ip либо вызовы Netlink, на которых эта команда основана.

Другой простой способ — разбор файла /etc/resolv.conf, который является динамически генерируемой символической ссылкой на файл в /run/systemd/resolve, такой как /run/systemd/resolve/stub-resolv.conf:

awk '/^[^#]*\s*nameserver/ {print($2); exit;}' /etc/resolv.conf

Полученный адрес может быть использован для подключения к приложениям на хостовой системе Windows.

Из хостовой системы обратиться к экземпляру Linux в WSL можно по его IP-адресу, который известен.

Помимо интернет-сокетов AF_INET, для связи между хостовой и гостевой системами будут работать именованные сокеты Unix, но доступ к ним из хостовой системы, очевидно, будет возможен, только если они расположены на смонтированных в хостовую систему ФС.

В WSL 1 файловые системы Linux были доступны в хостовой системе как обычные каталоги. Изменения в них могли не отразиться в гостевой системе, а доступ к ним был медленным.

В WSL 2 доступ к файловым системам осуществляется через файловый сервер Plan 9 по протоколу 9P. Этот вариант эффективен и не имеет проблем синхронизации.

Кроме того, стоит обратить внимание на, казалось бы, очевидное обстоятельство: сокеты в WSL не работают поверх WinSock API и не всегда работают поверх сетевого стека ОС Windows.

В случае WSL 2 сокеты реализованы в стеке Linux, а его выполнение осуществляется виртуальной машиной.

Резюме

В ОС Windows включено большое число сетевых библиотек, предоставляющих высокоуровневые API. Так, для работы со стандартными интернет-протоколами, такими как Gopher, FTP и HTTP, существует WinINet API. Этот интерфейс предоставляет разработчикам функции, упрощающие взаимодействие с сетевыми ресурсами. Его серверной высокоуровневой альтернативой является WinHTTP API.

Если говорить о еще более высокоуровневых решениях, стоит отметить Microsoft Foundation Classes, которые включают в себя классы для работы с сокетами.

Класс CSocket предоставляет возможность синхронной, или блокирующей, работы, что может быть полезно в определенных сценариях. Класс CAsyncSocket предназначен для асинхронной работы с сетевыми соединениями, что позволяет избежать блокировки потока исполнения во время ожидания ответа от сервера. А класс CArchive выполняет сериализацию и десериализацию, позволяя разработчикам легко передавать данные по сети.

Windows Filtering Platform — универсальная технология фильтрации сетевых данных, которая охватывает все основные уровни сетевой модели, от транспортного до канального, что позволяет не только фильтровать, но и изменять сетевые данные в реальном времени. В некотором роде это аналог BPF в ОС Windows.

Платформа фильтрации состоит из подсистемы фильтрации, базового модуля фильтрации, драйверов, оболочек совместимости и внешних модулей.

Возможность писать собственные модули делает WFP мощным инструментом для разработчиков.

Наконец, в современных версиях операционных систем Windows появилась возможность исполнять Linux-приложения, то есть файлы ELF64, без каких-либо изменений. За это отвечает подсистема Windows Subsystem for Linux. Теперь инструменты и приложения Linux можно использовать прямо из Windows. Однако стоит отметить, что сокеты в WSL работают с определенными ограничениями. В более современной версии WSL 2 этих ограничений значительно меньше по сравнению с WSL 1, что упрощает интеграцию Linux-приложений.

Вопросы и задания

1. Какие функции высокого уровня в ОС Windows могут использовать прикладные разработчики?

2. Для чего нужен Windows Internet API?

3. С какими протоколами работает Windows Internet API?

4. Как загрузить документ по HTTP, используя Windows Internet API?

5. В чем разница между функциями HttpOpenRequest() и HttpSendRequest()?

6. Каково назначение Windows HTTP API? Чем он отличается от Windows Internet API?

7. Какие примитивы для сетевой работы есть в MFC?

8. Какой класс сетевой подсистемы MFC поддерживает асинхронное взаимодействие?

9. Для чего в MFC используется класс CArchive?

10. Какой вызов в MFC создает подключение к FTP-серверу?

11. Как заставить MFC-сокет ожидать подключения? А как обработать новое подключение?

12. Из каких основных частей состоит WFP?

13. Для чего нужна система фильтрации WFP?

14. Что такое callout? Какие типы callout бывают?

15. Для чего может быть полезна WSL в контексте сетевого программирования?

16. Какие существуют ограничения на использование BSD-сокетов в WSL?

17. В чем отличие работы сетевых вызовов в WSL 1 и WSL 2?

18. Напишите HTTP-запрос POST, используя WinINet.

150 https://learn.microsoft.com/en-us/windows/win32/api/wininet/nf-wininet-internetopenurla

151 https://learn.microsoft.com/en-us/windows/win32/winhttp/porting-wininet-applications-to-winhttp

152 https://github.com/microsoft/mfcmapi

153 https://learn.microsoft.com/en-us/windows/win32/fwp/windows-filtering-platform-start-page

154 https://learn.microsoft.com/en-us/windows/win32/fwp/windows-filtering-platform-start-page

155 https://learn.microsoft.com/en-us/windows/msix/overview

156 https://learn.microsoft.com/en-us/windows/wsl/basic-commands

157 https://learn.microsoft.com/en-us/windows/wsl/install

Глава 21. Проксирование, инкапсуляция

Представьте себе все содержимое планетарных каналов данных, всю сумму человеческих знаний, вброшенную в хрупкую нейронную сеть Планетарного Разума силами всех имеющихся на планете реакторов. Это последняя попытка человечества спастись от уничтожения пробудившимся инопланетным богом...

Академик Прохор Захаров, «Говорит Планета», Sid Meier’s Alpha Centaury

Введение

В этой главе мы поговорим о том, как выполняются действия с потоками данных, такие как их перенаправление, фильтрация и перехват, на разных сетевых уровнях.

Очевидно, что любые данные создаются их производителем и отправляются потребителю с использованием каналов связи. Однако на разных уровнях в канале связи над передаваемыми данными могут производиться разные операции.

На транспортном и сетевом уровне это разбивка на порции и упаковка в PDU, например в сегменты TCP и пакеты IP при передаче данных, и обратный процесс в случае их приема. Как правило, за нас эти действия выполняет сетевой стек, если не используются raw-сокеты и сокеты из семейства AF_PACKET. На данном этапе разработчику могут быть полезны настройки параметров и опции данного процесса, которые были описаны в главе 8 об управлении сокетами.

На канальном и физическом уровне можно перехватывать данные, отправленные другому получателю, если они проходят через общий канал.

После того как данные ушли в канал и были доставлены на маршрутизатор, маршрутизатор определяет, кому их передавать, то есть на какой из портов отправить данные.

Если компьютер подключен напрямую к провайдеру, на вопрос, кому отправлять пакеты, будут отвечать маршрутизаторы провайдера.

Для этого сеть маршрутизаторов строит таблицу маршрутов. Это можно сделать двумя способами: статически, используя конфигурацию, заданную на каждом маршрутизаторе, либо динамически, используя для построения таблицы распределенные протоколы.

На основе характеристик данных, прежде всего адреса назначения, и некой метрики, например времени доставки, маршрутизатор выбирает лучший маршрут. Маршрутизаторы используют целый набор протоколов, которые делятся на работающие внутри провайдера и работающие между провайдерами.

Строго говоря, маршрутизатор использует протоколы внешнего шлюза и внутренние протоколы, которые работают в рамках автономной системы.

Автономная система характеризуется тем, что политика маршрутизации в ней едина и меняется согласованно.

При этом автономная система может принадлежать и нескольким провайдерам, если между ними существует договор. Либо, наоборот, одному крупному провайдеру может принадлежать несколько автономных систем, например, в разных странах.

Автономные системы обсуждаются подробнее в RFC 1930 «Guidelines for creation, selection, and registration of an Autonomous System (AS)».

Для построения маршрутов используются следующие протоколы:

• RIP, OSPF или IS-IS для связи внутри сети провайдера.

• Устаревший EGP, который сейчас заменен протоколом BGP/IDRP для связи между автономными системами.

Эти протоколы, как правило, работают поверх IP, за некоторым исключением: BGP в качестве транспорта использует TCP, а RIP — UDP. Их функционирование управляется сетевым администратором и неподконтрольно программисту. Поэтому в книге мы не будем рассматривать эти протоколы.

К этим же протоколам можно отнести и различные VLAN, которые могут разделять один физический порт, но использовать разные каналы через тегирование различных данных на канальном уровне.

Гораздо более интересна с точки зрения большинства разработчиков обработка данных на высоких уровнях OSI: сеансовом, уровне представления и прикладном.

Сеансовый уровень позволяет реализовать аутентификацию или работу VPN поверх TCP, используя такие протоколы, как PPTP, и поверх UDP через L2F и L2TP.

Уровень представления отражает различные методы изменения представления данных: сжатие, шифрование, сериализацию и т.д. Например, кодирование по ASN.1 в модели OSI применяется к данным на уровне представления. Сериализацию — преобразование данных в формат, удобный для передачи по сети, и различные методы выполнения этого преобразования мы разберем в книге 3.

Шифрование, например, с использованием TLS, усложняет анализ данных, перехваченных из канала на уровнях ниже, а также их подмену.

И наконец, прикладной уровень позволяет работать с данными на уровне протоколов, которые «понимают» их смысл. На этом уровне доступны подробные метаданные: кто, откуда, как и какие данные получил. А также обычно доступны сами данные. На этом уровне работают прокси-серверы, зависимые от конкретных протоколов, и DLP-системы, предотвращающие утечки конфиденциальных данных организации.

В этой главе мы рассмотрим именно проксирование. Подробно поговорим о прокси-серверах, о том, где они используются, чем отличаются от VPN, и реализуем собственный прокси-сервер.

Проксирование

Прокси — это сервер-посредник, который принимает запросы клиента, переадресовывает их целевому серверу и возвращает его ответы.

Обычно прокси знает протокол, используемый клиентом. Например, в WWW широко распространены HTTP- и HTTPS-прокси. Сведения о конкретном протоколе позволяют прокси выполнять широкий круг задач.

Рис. 21.1. Работа без прокси и через прокси

Работа прокси на примере HTTP 1.1 показана на рис. 21.1:

1. Клиент подключается к прокси-серверу.

2. Клиент выполняет GET-запрос к серверу: GET https://www.targetserver.com/path HTTP/1.1. Обратите внимание на особенность: в запросе указан полный URL, а не путь относительно корня.

3. Прокси делает запрос к серверу и возвращает ответ с данными клиенту.

Если не требуется выполнять задачи, специфичные для протокола, прокси служит лишь туннелем для произвольных данных. Например, в HTTP-прокси метод CONNECT используется для открытия туннеля. Параметрами метода являются узел и порт, куда необходимо проксировать данные.

Если прокси с поддержкой этого метода подключится к указанным узлу и порту, он вернет по HTTP код "200 OK" и начнет отправлять данные от клиента серверу и обратно. В этом случае знание HTTP-протокола нужно лишь для того, чтобы подключиться к прокси и сделать вызов.

HTTP- и HTTPS-прокси

Для работы с ресурсами, загружаемыми по HTTPS, прокси используют только HTTP-метод CONNECT, чтобы браузер мог проверить сертификаты целевого сервера.

Соединение между клиентом и прокси может устанавливаться как по HTTP, так и по HTTPS. В любом случае прокси может выполнять разные типы запросов:

• Методом GET — в этом случае можно обращаться только к незащищенным URI по HTTP.

• Методом CONNECT — для прочих ресурсов, например запрашиваемых по HTTPS.

При использовании метода CONNECT и HTTPS до целевого сервера трафик будет шифрованным и прокси не сможет его прочитать. То есть, как написано выше, прокси открывает канал и не знает, какие данные через него передаются, что позволяет в теории проксировать любой протокол.

При использовании HTTPS до прокси никто также не сможет прочитать запрашиваемые URL.

Очевидно, что любой прокси должен понимать, куда передавать данные клиента.

Посмотрим на рис. 21.2. В HTTP-прокси место назначения указывается либо через полный URL в запросе, либо с помощью HTTP-заголовка Host. Аналогично работает и метод CONNECT.

Рис. 21.2. Прохождение запросов через HTTP-прокси

В других типах прокси адрес назначения передается иными способами.

Например, SOCKS-протокол также распространен в интернете, и он, как и прокси, использующие HTTP-метод CONNECT, позволяет работать не только с WWW, но и с другими сервисами.

Не каждый протокол, выполняющий проксирование, работает на прикладном уровне. SOCKS, например, работает на сеансовом. От прикладного уровня он не зависит и потому может передавать любые данные.

В SOCKS адрес задается явно в PDU SOCKS протокола. SOCKS-прокси вообще используют бинарный протокол, не имеющий ничего общего с HTTP. Для SOCKS-прокси знание протокола требуется только для настройки соединения, а также указания того, куда открывать туннель.

SOCKS-протокол

На данный момент существует несколько версий SOCKS-протокола. Версия 4 очень проста. Структура PDU его запроса показана на рис. 21.3.

Рис. 21.3. Структура PDU запроса SOCKS4

Команда бывает двух типов:

• 0x01 — установить TCP-соединение. Подключение к заданному серверу через SOCKS.

• 0x02 — связать TCP-порт. В этом случае клиент говорит SOCKS-серверу о том, что он хочет принять входящее соединение. Этот режим обычно не используется.

Последним элементом структуры является нуль-завершенный идентификатор пользователя.

На основе IP-адресов, номеров портов и пользовательского идентификатора сервер SOCKS 4 определяет, принимать или отбрасывать подключение. Для этого он может отправить запрос на IDENT-сервер, описанный в RFC 1413 «Identification Protocol», который по указанным параметрам выдаст ответ: разрешить или отклонить доступ. Но этот вариант аутентификации давно устарел.

Если запрос удовлетворен, SOCKS-сервер устанавливает соединение с указанным портом узла назначения.

На запрос SOCKS4 прокси отправляет пакет ответа, показанный на рис. 21.4.

Рис. 21.4. Структура PDU ответа в SOCKS4

Коды ответа:

• 0x5a — ответ на запрос.

• 0x5b — запрос отклонен или произошла ошибка.

• 0x5c — недоступен identd, то есть нельзя проверить ID пользователя.

• 0x5d — identd не может подтвердить ID пользователя.

SOCKS 4a — совместимое расширение, которое добавило возможность задавать домен вместо IP-адреса.

SOCKS 5 — протокол с более широкими возможностями, чем SOCKS 4, но без обратной совместимости. Он добавляет схемы аутентификации, поддержку UDP, доменных имен и IPv6. Этот протокол описан в RFC 1928 «SOCKS Protocol Version 5» и нескольких сопряженных: RFC 1929 «Username/Password Authentication for SOCKS V5», RFC 1961 «GSS-API Authentication Method for SOCKS Version 5», в которых представлены методы аутентификации.

Типичный прокси необходим пользователям в следующих целях:

• Для получения доступа к ресурсу, который отказывает в соединении с данного IP либо к которому закрыт доступ на промежуточных маршрутизаторах.

• Для скрытия реального IP-адреса от удаленного абонента, например от веб-сервера. Это может быть полезно для:

• защиты от слежки за перемещением устройства;

• сокрытия устройств с «белым», то есть находящимся не за NAT, IP-адресом от возможных атак;

• устранения ограничений на количество запросов в секунду с одного IP-адреса. Это требуется, например, когда необходимо сохранить какой-либо сайт полностью.

Компании используют прокси в следующих целях:

• Закрытие доступа к определенным ресурсам по их адресам. Доступ в интернет есть только у прокси, и все другие узлы вынуждены подключаться к нему, чтобы получить нужные ресурсы.

• Разрешение доступа к ресурсам, в том числе внешним, только для авторизованных пользователей. Например, без авторизации доступны лишь ресурсы государственных служб, а после авторизации — любые ресурсы, не включенные в «черный список», который также может управляться прокси-сервером.

• Перехват и фильтр содержимого. Фильтровать его можно не только в ответах сервера, но и в запросах клиента: этим занимаются DLP-системы. Обратная сторона этой технологии — использование прокси для взлома. Наоборот, антивирусы работают как прокси для защиты — находят в трафике объекты и проверяют их.

• Кэширование и сжатие содержимого для ускорения ответа и экономии трафика. Например, в организации к веб-ресурсам обращается много пользователей через один веб-прокси, который может кэшировать проходящие данные и реже делать запросы в интернет. Также он может использовать современные расширения HTTP для сжатия трафика, даже если браузеры клиентов их не поддерживают.

• Балансировка нагрузки в случае использования нескольких каналов или маршрутизаторов и контролировать объем трафика от пользователей, причем как осуществляющих доступ к внешним ресурсам изнутри компании, так и внешних пользователей, которые запрашивают ресурсы компании. В первом случае прокси используется достаточно редко, балансировку выполняют на уровне нижележащей сетевой инфраструктуры, например на маршрутизаторах. А для второго случая использование прокси достаточно типично. Только в этом случае применяется другая схема его включения — обратный прокси, который будет рассмотрен далее.

Режимы проксирования

Часто задания прокси требует политика организации. Доступ из сети компании ко всем IP, кроме внутренних, блокируются маршрутизатором, и подключение к интернету осуществляется через прокси.

Прокси, с одной стороны, обладает внутренним IP, с другой — имеет выход во внешнюю сеть. Это позволяет организации контролировать трафик и балансировать нагрузку.

Существуют разные способы задания прокси:

• Явное задание в приложении. Некоторые приложения, такие как браузеры и утилиты для работы с Web, например wget и cURL, позволяют задавать прокси через настройки. Этот тип предполагает, что пользователю известно о наличии прокси, и требуется его задать.

• Прозрачное проксирование. В данном случае о прокси могут знать только сетевые или, реже, системные администраторы.

По той причине, что явное задание прокси специфично для каждого приложения и реализовано по-разному, больший интерес представляет прозрачный режим.

Прозрачное проксирование может выполняться на разных уровнях:

• На уровне сети. В этом случае отдельный маршрутизатор «заворачивает» соединения на прокси, выполняющий необходимую обработку трафика, и пересылает их дальше либо блокирует. Вероятно, это самый частый вариант реализации.

• На уровне операционной системы. Пример — системный сервис Redsocks158. Перенаправление осуществляется через брандмауэр. Данный прокси работает на уровне TCP-соединений и может применяться в том числе для перенаправления DNS-запросов, выполняемых по UDP, через протокол SOCKS 5.

• На уровне приложения. Если приложение не позволяет явно задать прокси, а проксирование на уровне сети или ОС не выполняется, можно перехватывать сетевые вызовы. Так работают приложения Sockschain159 или Win2socks160. Так же работает перехватчик вызовов, который будет реализован в главе 25.

Прозрачный L4-прокси

Отдельным интересным случаем можно считать прозрачный L4-прокси, то есть работающий на четвертом — транспортном — уровне OSI.

Например, это может быть тот же Nginx с включенным модулем stream161, позволяющим выполнять проксирование TCP и UDP. Он не знает о вышележащем протоколе и перенаправляет весь поступивший трафик на определенный IP-адрес или группу адресов.

В случае же использования таких модулей, как stream_ssl_prepead162, Nginx может читать имя хоста и протокол из SSL-запроса, перенаправляя SSL-трафик куда требуется, без его расшифровки.

Перенаправление на такой прокси весьма просто, оно может быть выполнено даже статически на уровне ОС, через записи в файле hosts. Этот вариант проксирования может быть полезен, например, для пересылки больших потоков данных, таких как видео, через указанные серверы.

Другой пример такого L4-прокси — утилита stunnel, которая слушает на определенном порту, шифрует весь приходящий на порт трафик и отправляет его на адрес и порт, указанные в ее конфигурационном файле.

Приложение, которое подключается на порт данной утилиты, считает, что взаимодействует не с прокси, а с целевым сервером, поэтому такое проксирование в какой-то степени тоже можно считать прозрачным.

Реализации прокси

Существует несколько реализаций прокси для протокола HTTP/HTTPS:

• Proxomitron163 — некогда мощный прокси для изменения веб-страниц под ОС Windows. Он работал на фильтрах, задаваемых регулярными выражениями, удалял из страниц рекламу и нежелательный контент. Также позволял корректировать запросы пользователя.

• Privoxy164 — кросс-платформенное приложение с открытым исходным кодом. Удаляет рекламу и нежелательное содержимое страниц. Поддерживает HTTP, HTTPS и SOCKS.

• Squid165 — известный кэширующий прокси-сервер с открытым исходным кодом. Поддерживает HTTP, HTTPS, FTP, Gopher и еще ряд протоколов. Не поддерживает SOCKS. Позволяет задавать частоту запросов к ресурсам: например, ограничивать трафик с видеосервисов. Проект кросс-платформенный, но в Windows новые версии работают только через Cygwin.

• Tinyproxy166 — маленький и эффективный прокси-сервер для небольших сетей. Позволяет кэшировать запросы и соединения, фильтровать трафик. Может работать как прозрачный прокси167. Исходный код на C открыт по лицензии GPL-2.

Proxomitron — характерный пример необходимости открытия исходных кодов. Это очень удобная и достаточно простая в управлении эффективная утилита. При этом бесплатная, поставляемая как Freeware.

Он значительно ускорял работу в интернете и делал ее безопаснее. Поэтому вокруг него вскоре сформировалось целое сообщество.

Автором утилиты был Скотт Р. Лемон, который в 2003 году прекратил разработку по неизвестным причинам, а в 2004 году скончался. Исходный код остался в собственности компании, в которой он работал. Она не развивает проект и, видимо, не использует, но код не открывает. А попытки обратной разработки оказались безуспешными.

Существуют отладочные прокси, которые предназначены для поиска ошибок в протоколах. Такие прокси имеют расширенные возможности протоколирования, возможность автоматического обнаружения несоответствий протоколу и расшифровки трафика при взаимодействии по SSL.

Примеры прокси, используемых для отладки HTTP и HTTPS:

• MitMproxy168 — с открытым исходным кодом. Поддерживает HTTP/1, HTTP/2, HTTPS, WebSocket и другие протоколы.

• Fiddler169 — достаточно зрелый и удобный проект, но, к сожалению, не бесплатный. Изначально это был прокси, но сейчас превратился в целый набор инструментов. Продукт кросс-платформенный, работает на Linux, Windows и MacOS. Поддерживает как HTTP версий 1 и 2, так и HTTPS и WebSocket. Позволяет устанавливать точки останова в сессии обмена, захватывать проходящий трафик, а также задавать правила, имитирующие ответ сервера.

• HTTP Debugger170 — работает без изменения конфигурации браузера и ручной установки прокси. Дает возможность просматривать различные типы передаваемых данных, находить медленные запросы, изменять HTTP трафик «на лету», имитировать ответы сервера, поддерживает сложную фильтрацию. В основном предназначен для отладки API, выявления проблем с производительностью и безопасностью. Платный инструмент.

• Charles Web Debugging Proxy171 — отладочный прокси, реализованный на Java. Зрелый проект, который был начат в 2002 году. Платный. Имеет такие же возможности, как у большинства подобных инструментов:

• Отображение источников сообщений для всех TCP-соединений, которые проходят через его прокси-порт.

• Возможность внутри HTTP-запроса или ответа просматривать XML, JSON и SOAP в древовидном формате.

• Подсветка для просмотра HTML, CSS и JavaScript.

• Отладка SSL — расшифровывает зашифрованные данные для просмотра/устранения неполадок в передаваемом контенте.

• Регулирование пропускной способности для имитации более низкой скорости интернета.

• Отладка HTTP-соединений с мобильных устройств.

• Возможность замены удаленных файлов локальными для облегчения отладки сайта без необходимости доступа к серверу.

• Вспомогательные средства отладки, такие как повторение запросов на публикацию URL-адресов для проверки изменений сервера, добавление точек останова или редактирование переменных запроса.

Для сетевого разработчика данные инструменты отладки весьма полезны.

Обратный прокси

Прокси может использоваться не только для получения доступа к ресурсам внешней сети. Другая схема его включения — перед серверами, предоставляющими эти ресурсы. В таком случае прокси называется обратным, или реверс-прокси.

Такой прокси ожидает запросов на определенном порту и внешнем адресе, а затем перенаправляет эти запросы сервису, который не виден клиентам напрямую. Эта техника широко используется для балансировки нагрузки, управления заголовками, передаваемыми серверам, внедрения внешней авторизации для доступа к сервису и т.п.

Программное обеспечение для обратного проксирования бывает разным. Зачастую, в случае WWW, используют веб-сервер, такой как Nginx, который изменяет запросы и переадресует их целевому серверу. Но можно использовать и обычный прокси-сервер, например тот же Squid.

В Squid реализован captive portal, используя который можно отображать веб-страницы, то есть он тоже имеет функциональность простого веб-сервера.

Существуют и специализированные балансировщики. Высоконагруженные сервисы, такие как Github, Stack Overflow, Reddit, используют HAProxy. Он удобен, так как сразу предоставляет возможности, которые полезны для балансировщика: гибкую проверку доступности бэкендов, переписывание URL согласно правилам, поддержку WebSocket, gRPC, ограничение скорости и защиту от DoS-атак, TLS, расшифровку трафика до серверов и т.п.

Сходное решение предоставляет Elastic Load Balancing, или ELB, от Amazon, который автоматически распределяет входящий трафик по нескольким узлам.

На рис. 21.5 показано, как используется обратный прокси. Важно понимать, что с точки зрения разработчика ситуации прямого и обратного проксирования принципиально не различаются. И в том и в другом случае прокси-сервер — это приложение, которое реализует определенный протокол, выполняет работу над единицами данных этого протокола, а затем переадресует их некоторым целевым абонентам или отбрасывает.

Рис. 21.5. Схема включения обратного прокси-сервера

Разница есть лишь с точки зрения пользователя и сетевого администратора: в случае прямого использования прокси его устанавливает пользователь или компания и через него проходят запросы «куда бы то ни было» от пользователей.

На обратный прокси запросы приходят «откуда бы то ни было», а за его настройку отвечает администратор сервиса, предоставляющего ресурс.

Прямой и обратный HTTP-прокси

Небольшое техническое различие между прямым и обратным HTTP-прокси все же есть. Обратный прокси не должен выполнять перенаправление трафика на произвольный URL,а только на заданные по определенным правилам. А значит, он не должен поддерживать HTTP-метод CONNECT, часто используемый в HTTP-прокси для установки туннеля.

Тот же Nginx, нередко применяемый для этой задачи, просто отправляет запрос на выбранный сервис, работающий во внутренней сети, и перенаправляет клиенту ответ с данными. Метод CONNECT он сам по себе не поддерживает: использовать полноценный веб-сервер как обычный прокси странно и небезопасно.

Хотя, при желании, тоже возможно. Например для Nginx существует модуль ngx_http_proxy_connect_module172, который добавляет поддержку этого метода, превращая его в полноценный прямой прокси.

Прокси-протокол

На практике вы можете встретить такое понятие, как «протокол проксирования», или Proxy Protocol. Например, он реализован в AWS Elastic Load Balancing.

Данный протокол обычно используется для взаимодействия обратного прокси и серверов. При этом необходимо настроить на сервере модуль для поддержки этого протокола.

Протокол нужен, когда от прокси требуется сохранить для сервера IP-адрес реального клиента и прочие метаданные, например, чтобы сервер мог корректно определять географический регион клиента.

Протокол дает возможность установки соединения между источником и назначением через прокси-сервер, то есть это проксирование на транспортном уровне. Он полезен, например, для того, чтобы добавить к заголовку запроса заголовок с информацией о соединении, которую возвращают функции getsockname() и getpeername():

• Семейство адресов: AF_INET для IPv4, AF_INET6 для IPv6, AF_UNIX.

• Протокол транспортного уровня.

• Адреса источника и назначения сетевого уровня.

• Порты источника и назначения транспортного уровня, если таковые имеются.

Данный протокол предложен и впервые реализован разработчиками HAProxy и сейчас поддерживается как минимум Nginx и ELB.

На момент написания книги существует две версии протокола:

• Версия 1 — полностью текстовая.

• Версия 2 — с бинарным заголовком.

Весь протокол заключается в следующих несложных правилах:

1. К исходным данным будет добавлен заголовок, указывающий параметры подключения, перечисленные выше.

2. Гарантируется, что заголовок нельзя перепутать с общими протоколами более высокого уровня, такими как HTTP, SSL/TLS, FTP или SMTP, и что форматы версии 1 и версии 2 легко отличимы друг от друга.

3. Оба формата предназначены для размещения в наименьшем TCP-сегменте, равном 536 байт, что гарантирует доставку заголовка сразу и полностью за один вызов send() и recv().

4. Получатель не должен начинать обработку соединения до получения полного заголовка протокола.

5. Получатель может быть терпимым к частичным заголовкам и ожидать прихода заголовка некоторое время или просто сбрасывать соединение. Обычно тайм-аут устанавливается от 3 секунд.

6. Получатель должен всегда ожидать заголовка прокси-протокола, а не пытаться угадать, есть он или нет. Это требуется для исключения проблем с безопасностью.

Из примера ниже станет понятно, что представляет собой заголовок протокола версии 1:

PROXY TCP4 192.168.0.1 192.168.0.11 56324 443

GET / HTTP/1.1

Host: 192.168.0.11

О наличии данного протокола имеет смысл знать, если вы хотите реализовать свой прокси, особенно используемый в качестве обратного: лучше не придумывать свой «лучший протокол» и не реализовывать для веб-серверов его поддержку, а взять готовый и проверенный.

Прозрачное проксирование в Linux

В различных сценариях, например, если необходимо маршрутизировать пакеты на основе их содержимого, а не адресов источника или назначения, требуется обработка пакета приложением, а не просто его перенаправление.

Но для того, чтобы пакет был отправлен приложению, его адрес назначения должен совпадать с адресом узла. Иначе пакет будет отброшен или перенаправлен, если перенаправление включено и возможно.

Если сконфигурировать узел так, что все пакеты будут «локальными», то есть якобы имеющими адрес 0.0.0.0/0, подключиться с узла к любому другому узлу будет невозможно.

Опция сокетов IP_TRANSPARENT отключает проверку IP-адреса источника. Это позволяет приложению обрабатывать пакеты, проходящие через сокет, как будто они были отправлены локальному узлу.

Кроме того, данная опция позволяет получать пакеты пакетов, перенаправленных через iptables TPROXY:

➭ iptables -t mangle -A PREROUTING ! -d ${PROXY_IP} -p tcp -j TPROXY --on-port 8080 --on-ip 127.0.0.1 --tproxy-mark 0x1/0x1

➭ ip rule add fwmark 1 lookup 100

➭ ip route add local 0.0.0.0/0 dev lo table 100

В данном сценарии перенаправляется TCP-трафик, приходящий на порт 8080, кроме трафика, генерируемого самим прокси-сервером.

Приложение может также привязать прослушивающий сокет к любому нелокальному адресу или к 0.0.0.0. И в случае использования TCP функция accept() на этом сокете привяжет нелокальный адрес узла назначения, то есть getsockname() будет возвращать адрес другого узла, того, к которому реально выполнялось подключение.

Для получения адреса в случае UDP необходимо использовать опцию IP_RECVORIGDSTADDR, которая включает отправку содержащего адрес sockaddr_in или sockaddr_in6 вспомогательного сообщения IP_ORIGDSTADDR на уровне SOL_IP.

Пример ее использования:

import socket

# Значение опции.

IP_TRANSPARENT = 0x13

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# Включить опцию.

sock.setsockopt(socket.IPPROTO_IP, IP_TRANSPARENT, 1)

# Под root эта привязка будет работать.

sock.bind(('8.8.8.8', 1234))

sock.listen()

print(f'Listening on {sock.getsockname()}')

while True:

cln_sock, (remote_ip, remote_port) = sock.accept()

local_ip, local_port = cln_sock.getsockname()

print(f'Connection from {remote_ip}:{remote_port} to '

f'{local_ip}:{local_port}')

cln_sock.close()

Например, для HTTP может быть выполнена маршрутизация по URL-адресу внутри HTTP-запроса или по адресу в заголовке Host.

Процесс работы прокси-сервера в этом случае таков:

1. Узел перенаправляет трафик, проходящий через него.

2. Сервер создает сокет, например, на порту 80, и включает опцию IP_TRANSPARENT.

3. Когда через этот узел проходит пакет для другого узла, идущий на порт 80, ядро операционной системы обрабатывает его как пакет, полученный на локальном устройстве, и передает серверу.

4. Сервер анализирует информацию внутри пакета, например URL-адрес внутри HTTP-запроса.

5. На основе этой информации он выбирает нужный удаленный сервер и отправляет на него запрос.

6. Запрос уходит уже на другой сервер, а не на тот, адрес которого был указан изначально в IP-заголовке.

7. Когда приходит ответ от удаленного сервера, сервер отправляет его обратно клиенту через тот же локальный сокет, через который он принял запрос.

Данная опция может быть полезна не только для прокси-серверов, но и, например, для балансировщиков нагрузки.

Внимание! Для работы этой опции должно быть включено перенаправление через SysFS-опцию net.<ipv4|ipv6>.conf.all.forwarding и отключен фильтр обратного пути через net.ipv4.conf.<имя_адаптера>.rp_filter. Использование опции требует привилегий администратора.

IP_FREEBIND — схожая опция, но она позволяет привязать нелокальный адрес только к TCP-сокету. Существует настройка net.<ipv4|ipv6>.ip_nonlocal_bind, разрешающая такую привязку.

Эту опцию удобно использовать, чтобы передавать оригинальный адрес источника бэкенду — тому же Nginx, который находится за обратным прокси.

Если на сокете прокси-сервера установлена эта опция, бэкенду передается реальный адрес подключившегося клиента, а не адрес обратного прокси. Он может быть, например, записан в журнал.

Как рассказал Алексей Кузнецов, изначально опция была внедрена для реализации прозрачного кэширования трафика, и сейчас лучше ее не использовать.

Использование опции IP_TRANSPARENT может привести к проблемам с безопасностью. Например, злоумышленник может отправлять пакеты на локальный порт, который был открыт с опцией IP_TRANSPARENT. Это может привести к обходу механизмов защиты.

Примеры HTTP Proxy

Посмотрим, как работает и реализуется прокси, на примере простого HTTP прокси-сервера. Для начала установим прокси в браузере. На рис. 21.6 показана часть окна настройки сети в браузере Firefox:

Хотя мы еще разберем HTTP-протокол подробнее в книге 2, читатель уже должен как минимум представлять его основы.

Рис. 21.6. Часть окна настройки сети в браузере Firefox

Для начала посмотрим, что делает браузер при работе через прокси. В качестве «прокси» используем Netcat, а в браузере зайдем, например, на сайт ya.ru.

В результате получим такой вывод Netcat:

➭ nc -l -p 8080

GET http://ya.ru/ HTTP/1.1

Host: ya.ru

User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8

Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3

Accept-Encoding: gzip, deflate

DNT: 1

Connection: keep-alive

Cookie: is_gdpr=0; is_gdpr_b=COaIGBC4TygC; yp=1643621751.ygu.1; mda=0; yandexuid=4936181221536216044; yandex_gid=16; ndsp=eyJk...ODI0fQ%3D%3D

Upgrade-Insecure-Requests: 1

Cache-Control: max-age=0

Из него видно, что при использовании прокси, как и предполагалось, браузер отправляет в GET-запросе полное имя узла, а также обязательный заголовок Host, соответствующий имени целевого узла. Используя эти данные, HTTP-прокси и определяет целевой узел. Реализация такого прокси рассмотрена далее, а его полный код вы можете увидеть в репозитории книги.

Прокси на C++

Рассмотрим основные участки кода, чтобы понять, как работает проксирование.

Метод ProxyServer::connect_to_target_server() выполняет подключение к целевому узлу. Изучим сначала его IPv4-часть:

// Метод для подключения к целевому узлу.

socket_wrapper::Socket

ProxyServer::connect_to_target_server(const std::string &host_name,

unsigned short port,

socket_wrapper::Socket &sock)

{

// Получить адреса для подключения. Функция из обертки.

// Вызывает getaddrinfo().

const auto servinfo = socket_wrapper::get_client_info(host_name, port);

// DNS может выдать несколько адресов.

// Не все они потенциально рабочие.

// Соединиться, используя первый адрес, который принимает соединения.

for (auto const *s = servinfo; s != nullptr; s = s->ai_next)

{

assert(s->ai_family == s->ai_addr->sa_family);

if (AF_INET == s->ai_family)

{

sockaddr_in *const sin =

reinterpret_cast<sockaddr_in *const>(s->ai_addr);

in_addr addr;

addr.s_addr =

*reinterpret_cast<const in_addr_t *>(&sin->sin_addr);

sin->sin_family = AF_INET;

sin->sin_port = htons(port);

std::array<char, INET_ADDRSTRLEN> ip;

std::cout

<< "Trying IP Address: "

<< inet_ntop(AF_INET, &addr, ip.data(), ip.size())

<< std::endl;

socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};

// Попытка соединения.

if (try_to_connect(sock, reinterpret_cast<const sockaddr *>(sin),

sizeof(sockaddr_in)))

{

// Если попытка удалась, этот адрес и сокет будут

// использоваться дальше.

return sock;

}

}

Часто сервер предоставляет несколько адресов, и при неудачной попытке подключения к одному необходимо попробовать следующий, что этот метод и делает.

Если вы читали главы про сеть в ОС Windows, то наверняка заметили, что в ней уже реализована функция WSAConnectByList(), которая делает подобное.

Аналогично выполняется обработка адресов IPv6:

// То же самое для IPv6.

else if (AF_INET6 == s->ai_family)

{

sockaddr_in6 *const sin =

reinterpret_cast<sockaddr_in6 *const>(s->ai_addr);

sin->sin6_family = AF_INET6;

sin->sin6_port = htons(port);

// Все то же самое, но с IPv6-адресом.

std::array<char, INET6_ADDRSTRLEN> ip6;

std::cout

<< "Trying IPv6 Address: "

<< inet_ntop(AF_INET6, &(sin->sin6_addr), ip6.data(),

ip6.size())

<< std::endl;

socket_wrapper::Socket sock = {AF_INET6, SOCK_STREAM,

IPPROTO_TCP};

// Попытка соединения.

if (try_to_connect(sock,

reinterpret_cast<const sockaddr *>(sin),

sizeof(sockaddr_in6)))

{

// Соединение удачно, вернуть сокет.

return sock;

}

}

}  // Окончание цикла for

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(),

"Connection error");

}

Метод, показанный ниже, в случае ошибки формирует HTTP-ответ пользователю в виде HTML-страницы, которая содержит текст, уведомляющий о том, что произошла ошибка:

void ProxyServer::client_error(

const socket_wrapper::Socket &sock, const std::string &cause,

int err_num, const std::string &short_message,

const std::string &long_message) const

{

std::string err_headers = "HTTP/1.0 " + std::to_string(err_num) + " " +

short_message + "\r\n";

// Отправить строку HTTP-ответа.

if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))

{

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

// Заголовок, говорящий о том, что будет отправлена HTML-страница.

err_headers = "Content-type: text/html\r\n";

if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))

{

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

std::stringstream err_body_s;

err_body_s

<< "<html><title>Proxy Error</title>" << "<body bgcolor=0xffffff>\r\n"

<< err_num << ": " << short_message << "\r\n"

<< "<p>" << long_message << ": " << cause << "\r\n"

<< "<hr><em>Example Proxy Server</em>\r\n" << "</body></html>\r\n";

auto err_body = err_body_s.str();

err_headers = "Content-length: " + std::to_string(err_body.size()) +

"\r\n\r\n";

// Отправить HTTP-заголовки.

if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))

{

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

// Отправить клиенту HTTP-тело, то есть сгенерированную HTML-страницу

// с ошибкой. Все это можно было сделать и за один вызов send().

if (-1 == send(sock, &err_body.at(0), err_body.size(), 0))

{

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

}

Метод parse_headers() разбирает заголовки. На место пропущенных заголовков данный прокси добавляет свои, зашитые в коде. Например, заголовок User-Agent будет подменен, и сервер не узнает, каким браузером вы пользуетесь, что может повлечь за собой как позитивные, так и негативные последствия:

// Здесь разбираются заголовки HTTP.

// Часть из них пропускается.

// В методе proxify() вместо них будут добавлены заголовки,

// которые зашиты в коде.

std::tuple<std::string, std::string> ProxyServer::parse_request_headers(socket_wrapper::Socket &s) const

{

std::string line;

std::stringstream result;

std::string host_name;

static constexpr char host_header_name[] = "Host: ";

do

{

line = read_line(s);

if (("\r\n" == line) || ("\n" == line))

continue;

// Эти заголовки будут пропущены.

if (line.find("User-Agent:") != std::string::npos)

continue;

if (line.find("Accept:") != std::string::npos)

continue;

if (line.find("Accept-Encoding:") != std::string::npos)

continue;

if (line.find("Connection:") != std::string::npos)

continue;

if (line.find("Proxy-Connection:") != std::string::npos)

continue;

// Тут будет получено имя узла из заголовка Host.

// К этому узлу в дальнейшем и будет осуществляться подключение.

if (const auto host_pos = line.find(host_header_name);

host_pos != std::string::npos)

{

// Заголовок найден, извлечь доменное имя.

host_name = line.substr(host_pos +

std::size(host_header_name) - 1);

continue;

}

// Остальные заголовки.

result << line;

} while ((line != "\r\n") && (line != "\n"));

return make_pair(result.str(), host_name);

}

Больше всего нас интересует метод proxify(), который выполняет основную работу:

• Прием запроса от клиента.

• Разбор запроса и возможное изменение его заголовков и тела.

• Отправка целевому серверу измененного запроса.

• Прием ответа сервера.

• Разбор ответа и возможное изменение его заголовков и тела, что здесь не реализуется.

• Отправка измененного ответа клиенту по запросу.

Для разбора URL внутри метода proxify() используется метод parse_uri(), выделяющий различные компоненты из строки. Он использует регулярное выражение. Рассматривать сам метод мы не будем, только приведем его прототип:

// Метод выделяет имя хоста, порт и путь к ресурсу.

ProxyServer::uri_data ProxyServer::parse_uri(const std::string &uri) const;

Кроме вызова регулярного выражения, выделения групп и формирования строкового потока, в нем почти ничего нет.

Сначала proxify(), выполнив чтение строки из сокета и ее разбивку на компоненты, проверяет соответствие метода HTTP:

void ProxyServer::proxify(socket_wrapper::Socket client_socket)

{

// В этом методе выполняется проксирование.

std::cout << "Waiting for the client request..." << std::endl;

try

{

std::string method;

std::string uri;

std::string version;

auto line = read_line(client_socket);

// Получить метод запроса, URL и версию HTTP.

std::stringstream ss(rtrim(line));

ss >> method >> uri >> version;

std::cout

<< "Client request: \"" << line << "\" parsed.\n"

<< "Method = " << method << "\n"

<< "URI = " << uri << "\n"

<< "Version = " << version

<< std::endl;

// Сейчас поддерживается только метод GET.

if (method != "GET")

{

std::cerr

<< "Unknown method: \"" << method << "\""

<< std::endl;

client_error(client_socket, method, 501, "Not implemented",

"This proxy does not implement this method");

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

Для упрощения примера мы реализовали поддержку только GET-запросов. Следующий этап — разбор URL и заголовков:

// Получим имя хоста, путь к ресурсу на сервере и порт

// из строки запроса.

auto [host_name, path, port] = parse_uri(uri);

std::cout

<< "Host name from the request = " << host_name << "\n"

<< "Path = " << path << "\n"

<< "Port from the request = " << port

<< std::endl;

// Разбираем HTTP-заголовки клиента.

auto [new_headers, host_name_from_header] =

parse_request_headers(client_socket);

if (host_name_from_header.size())

{

std::tie(host_name, std::ignore, port) =

parse_uri(host_name_from_header);

std::cout

<< "Host from the header = " << host_name << "\n"

<< "Port from the header = " << port

<< std::endl;

}

Теперь нужно выяснить, куда отправлять запрос, скорректировать заголовки и построить новый запрос:

// Создаем новый запрос, который будет отправлен на сервер.

std::stringstream new_request_s;

// Запрос.

new_request_s << "GET " << path << " HTTP/1.1\r\n";

// HTTP-заголовки.

new_request_s

<< "Host: " << host_name << "\r\n"

<< user_agent_hdr

<< accept_hdr

<< accept_encoding_hdr

<< connection_hdr

<< proxy_conn_hdr

<< new_headers

<< "\r\n";

const std::string& new_request = new_request_s.str();

std::cout

<< "\n"

<< "============\n"

<< "New request:\n"

<< "------------\n"

<< new_request

<< "============\n\n"

<< "Connecting to host: \"" << host_name

<< ":" << port

<< "\"..."

<< std::endl;

Отправим запрос на целевой узел:

// Создаем новое подключение к целевому серверу.

auto &&proxy_to_server_socket = connect_to_target_server(host_name,

port);

std::cout

<< "Connected.\n\n"

<< "Writing HTTP request to the target server..."

<< std::endl;

// Передаем запрос пользователя с заголовками,

// модифицированными прокси-сервером.

if (send(proxy_to_server_socket, &new_request.at(0),

new_request.size(), 0) < 0)

{

client_error(client_socket, method, 503, "Internal error",

sock_wrap_.get_last_error_string());

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

std::cout

<< "Request was written.\n\n"

<< "Reading response from the target server..."

<< std::endl;

Остается только прочитать ответ узла и перенаправить его клиенту, использующему прокси:

std::string res0ponse;

do

{

// HTTP 1.x — текстовый протокол.

// Можно читать ответ построчно,

// хотя это неэффективно.

line = read_line(proxy_to_server_socket);

response += line;

} while (line.size());

std::cout

<< "Response was read.\n"

<< "=======================\n"

<< "Target server response:\n"

<< "-----------------------\n"

<< response

<< "\n=======================\n"

<< std::endl;

std::cout

<< "Forwarding response to the client..."

<< std::endl;

// Передать ответ целевого сервера клиенту.

if (send(client_socket, &response.at(0), response.size(), 0) < 0)

{

throw std::system_error(sock_wrap_.get_last_error_code(),

std::system_category(), "send");

}

}

catch(const std::exception &e)

{

std::cerr << e.what() << std::endl;

}

catch(...)

{

std::cerr << "Unknown exception in the client thread!" << std::endl;

}

}

Попробуем запустить сервер и открыть http://neverssl.com в браузере. Ниже приведен сокращенный ответ. Видим, как отправляется запрос:

➭ build/bin/b01-ch21-simple-http-proxy 8080

Listening on port 8080...

Accepted connection

Creating client thread...

Waiting for the client request...

Client request: "GET http://neverssl.com/ HTTP/1.1" parsed.

Method = GET

URI = http://neverssl.com/

Version = HTTP/1.1

Host name from the request = neverssl.com

Path = /

Port from the request = 80

Host from the header = neverssl.com

Port from the header = 80

============

New request:

------------

GET / HTTP/1.1

Host: neverssl.com

User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0

Accept: text/html,application/xhtml+xml,application/xml;

Accept-Encoding: identity

Connection: close

Proxy-Connection: close

Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3

DNT: 1

Cookie: PHPSESSID=f9s83rf9p85huvp6rtk6fqm3l5; XYZSRV=wtc2-c

Upgrade-Insecure-Requests: 1

============

Connecting to host: "neverssl.com:80"...

Trying IP Address: 52.222.174.19

Connected.

Writing HTTP request to the target server...

Request was written.

Reading response from the target server...

Response was read.

После чего браузер получает основную HTML-страницу и делает несколько запросов для получения ресурсов:

=======================

Target server response:

-----------------------

HTTP/1.1 200 OK

Date: Tue, 09 Jul 2024 20:58:59 GMT

Server: Apache/2.4.58 ()

Upgrade: h2,h2c

Connection: Upgrade, close

Last-Modified: Wed, 29 Jun 2022 00:23:33 GMT

ETag: "f79-5e28b29d38e93"

Accept-Ranges: bytes

Content-Length: 3961

Vary: Accept-Encoding

Content-Type: text/html

<html>

<head>

<title>NeverSSL — Connecting ... </title>

...

</head>

<body>

...

<h2>What?</h2>

<p>This website is for when you try to open Facebook, Google, Amazon, etc

on a wifi network, and nothing happens. Type "http://neverssl.com"

...

<a href="https://twitter.com/neverssl">Follow @neverssl</a>

</noscript>

</div>

</div>

</body>

</html>

=======================

Forwarding response to the client...

Accepted connection

Creating client thread...

Waiting for the client request...

Client request: "GET http://oldwholeyoungrain.neverssl.com/online HTTP/1.1" parsed.

...

Иногда браузер пытается использовать метод CONNECT, который нашим прокси сейчас не поддерживается:

Listening on port 8080...

Accepted connection

Creating client thread...

Waiting for the client request...

Client request: "CONNECT alive.github.com:443 HTTP/1.1" parsed.

Method = CONNECT

URI = alive.github.com:443

Version = HTTP/1.1

Unknown method: "CONNECT"

Видно, что прокси может быть помехой в канале и искажать данные, но может быть и полезным инструментом диагностики различных проблем, в том числе связанных с нарушениями протокола. В любом случае при диагностике канала учитывайте возможное наличие прокси-сервера.

Прокси на Python

На Python базовый прокси выглядит так:

import sys

from proxy import entry_point

if __name__ == '__main__':

sys.exit(entry_point())

Понятно, что здесь прокси-сервер реализуется библиотекой. И в данном случае это Proxy.Py173 — библиотека проксирования с широкими возможностями, которая поддерживает DoH, TLS, плагины, обратное проксирование, SSH-туннелирование и многое другое.

Мы выбрали именно эту библиотеку, потому что она достаточно популярна. И хотя ее код далеко не идеален, ее будет интересно разобрать как пример долго живущего, реально используемого открытого проекта. Будем рассматривать версию 2.4.3.

Запустим прокси:

➭ src/book01/ch20/python/proxy-py-test.py --port 12345

2022-08-03 18:06:25,689 — pid:1305964 [I] plugins.load:85 — Loaded plugin proxy.http.proxy.HttpProxyPlugin

2022-08-03 18:06:25,691 — pid:1305964 [I] tcp.listen:80 — Listening on 127.0.0.1:12345

2022-08-03 18:06:25,715 — pid:1305964 [I] pool.setup:105 — Started 8 acceptors in threadless (local) mode

2022-08-03 18:07:32,569 — pid:1305968 [I] server.access_log:384 — 127.0.0.1:47900 — GET ya.ru:80/ — 301 Moved permanently — 913 bytes — 1700.27ms

И проверим работоспособность через Netcat:

➭ nc localhost 12345

GET http://ya.ru/ HTTP/1.1^M

Host: ya.ru^M

^M

HTTP/1.1 301 Moved permanently

Cache-Control: max-age=1209600,private

Location: https://ya.ru/

NEL: {"report_to": "network-errors", "max_age": 86400, "success_fraction": 0.001, "failure_fraction": 0.1}

P3P: policyref="/w3c/p3p.xml", CP="NON DSP ADM DEV PSD IVDo OUR IND STP PHY PRE NAV UNI"

Portal: Home

Report-To: { "group": "network-errors", "max_age": 86400, "endpoints": [ { "url": "https://dr.yandex.net/nel"}]}

Transfer-Encoding: chunked

X-Content-Type-Options: nosniff

X-Yandex-Req-Id: 4688-vla-l7-balancer-8080-BAL-5190

set-cookie: is_gdpr=0; Path=/; Domain=.ya.ru; Expires=Fri, 02 Aug 2024 15:02:12 GMT

set-cookie: is_gdpr_b=LFoSIRANraDaAe==; Path=/; Domain=.ya.ru; Expires=Fri, 02 Aug 2024 15:02:12 GMT

set-cookie: _yasc=kQ..vmw==; domain=.ya.ru; path=/; expires=Fri, 02-Sep-2022 15:02:12 GMT; secure

0

^M

Внимание! Данный прокси не работает с переводами строки в запросе. Строки обязательно должны оканчиваться символами возврата каретки и перевода строки — \r\n. Чтобы вставить символы, в консоли необходимо нажать Ctrl+V+Enter, что будет отображаться как ^M, а затем еще раз нажать Enter.

Функционирование прокси понятно из предыдущего кода на C++, потому сразу перейдем к рассмотрению кода библиотеки.

После установки через pip прокси можно запустить напрямую, без написания Python-кода:

➭ proxy

2022-08-21 03:11:02,701 — pid:828112 [I] plugins.load:85 — Loaded plugin proxy.http.proxy.HttpProxyPlugin

2022-08-21 03:11:02,701 — pid:828112 [I] tcp.listen:80 — Listening on 127.0.0.1:8899

2022-08-21 03:11:02,713 — pid:828112 [I] pool.setup:105 — Started 8 acceptors in threadless (local) mode

2022-08-21 03:11:04,861 — pid:828112 [I] proxy._handle_exit_signal:325 — Received signal 2

2022-08-21 03:11:04,861 — pid:828112 [I] pool.shutdown:125 — Shutting

down 8 acceptors

Вызывающий его файл будет помещен в соответствующий каталог bin: /usr/bin или .local/bin/proxy.

Сначала посмотрим на структуру кода. В корне библиотеки содержится несколько каталогов и файлов:

• common — различные общие файлы. Интерес представляют следующие:

• constants.py — сюда вынесены значения по умолчанию.

• flag.py — обертка над парсером агрументов, которая добавляет базовые параметры командной строки и устанавливает их значения по умолчанию.

• pki.py — функции для работы с ключами PKI. Не используются при работе прокси. Это отдельная утилита, которая служит для упрощения, например, генерации ключей администратором.

• plugins.py — класс, реализующий загрузку плагинов.

• utils.py — разные функции, в том числе отвечающие за соединение и его обертывание в шифрующий контекст TLS. Наличие такого файла характерно для многих проектов. Но это антипаттерн. И вероятно, автор впоследствии разобьет его на более мелкие файлы, сгруппировав функции по их назначению.

• core — ядро библиотеки:

• acceptor — реализация класса Acceptor, принимающего соединения, и пула акцепторов.

• base — базовые классы для обработчика HTTP и SOCKS-протокола — BaseTcpServerHandler, туннеля — BaseTcpTunnelHandler, обратного прокси и пула серверов — TcpUpstreamConnectionHandler.

• connection — соединения. Базовый класс TcpConnection, содержащий методы отправки и приема данных и выполняющий буферизацию. Его наследники: TcpClientConnection для обертывания клиентских сокетов и TcpServerConnection — подключение к целевым, или «восходящим», серверам. А также класс UpstreamConnectionPool, содержащий пул «восходящих» соединений.

• event — подсистема обмена событиями: диспетчер, очередь, менеджер и подписчик.

• listener — прослушиватели, ожидающие подключения от клиента: BaseListener — базовый класс, TcpSocketListener — по TCP, UnixSocketListener — через сокеты Unix-domain. ListenerPool — пул, управляющий созданием и временем жизни листенеров.

• ssh — поддержка соединений по SSH-протоколу.

• tls — поддержка TLS: слоя шифрования и аутентификации, протокола HTTPS.

• work — различные исполнители: безпотоковый, многопоточный и т.п., классы Task и Work, содержащие выполняемую задачу. С точки зрения сетевого программирования нам они не очень интересны.

• dashboard — плагин, реализующий панель управления сервером, его веб-интерфейс.

• http — поддержка HTTP и HTTPS:

• exception — ошибки, отправляемые сервером в ответ HTTP.

• inspector — поддержка Chrome DevTools Protocol. Дает возможность анализировать и менять проходящий через прокси HTTP трафик, пользуясь инструментарием браузера Google Chrome.

• parser — парсер запросов и ответов HTTP.

• proxy — класс HttpProxyBasePlugin. Базовый для плагинов HTTP-прокси. И класс HttpProxyPlugin, реализующий HTTP-прокси, а также управление его плагинами.

• server — локальный веб-сервер, используемый, когда прокси работает как обычный сервер, и обратный прокси — класс ReverseProxy.

• websocket — плагин и классы для поддержки WebSocket-протокола.

• descriptors.py — класс DescriptorsHandlerMixin, используемый для работы с циклами обработки событий.

• handler.py — класс HttpProtocolHandler: обработчик HTTP, HTTP2, HTTPS и WebSocket. Это класс-обработчик по умолчанию — DEFAULT_WORK_KLASS.

• url.py — класс Url, поддерживающий разбор URL и его десериализацию.

• plugin — различные плагины:

• имитация ответов сервера через REST API;

• фильтрация по URL и IP;

• поддержка сокращенных имен популярных сайтов;

• подмена DNS на пользовательский;

• изменение тела HTTP-запросов;

• поддержка на уровне прокси дискового кэширования получаемых объектов

и многие другие.

• socks — поддержка SOCKS-протокола: клиент, обработчик, парсер.

• testing — класс для организации тестирования.

• proxy.py — файл, содержащий объединяющий класс Proxy.

• __init__.py — при импортировании главного модуля здесь экспортируются часто используемые модули, чтобы было возможно осуществлять импорт сразу из proxy.

• __main__.py — точка входа для запуска прокси через python -m proxy.

Теперь взглянем на структуру классов, показанную на рис. 21.7. Мы убрали поддержку TLS, SSH, WebSocket и некоторые избыточные связи.

Рис. 21.7. Структура классов Proxy.Py

Видим, что данная библиотека достаточно сложна. Из всего обилия ее сущностей интерес для нас представляют сетевые вызовы, которые мы в основном и будем изучать в данной главе. Но сначала разберемся в общем процессе функционирования библиотеки.

На рис. 21.8 показано, как прокси-сервер принимает входящие соединения. Объект ListenerPool прослушивает настроенный порт сервера. Операции accept() на сокетах, готовых для приема входящих клиентских соединений, выполняются объектом класса AcceptorPool — пулом акцепторов.

Рис. 21.8. Работа класса AcceptorPool

По умолчанию proxy.py попытается использовать все доступные ему ядра ЦП для приема новых клиентских соединений.

Если режим без использования потоков включен, настраивается экземпляр ThreadlessPool, который запускает процессы Threadless для обработки входящих клиентских соединений.

Принятое клиентское соединение делегирует процессу через класс обработчика соответствующий процесс Acceptor.

В объекте класса прокси-сервера содержится пул соединений к целевому серверу. Из пула он получает соединение для каждого запроса клиента, отправляет запрос, используя это соединение, и возвращает клиенту результат по завершении запроса.

Классом-обработчиком по умолчанию является HttpProtocolHandler, который обрабатывает запросы клиентов по HTTP.

В proxy.py все является подключаемым модулем или плагином:

• Конкретные реализации HTTP-прокси и HTTP-сервера представляют собой подключаемые модули HttpProtocolHandler.

• Прокси-сервер реализуется плагином HttpProxyPlugin.

• Все плагины прокси-сервера, то есть плагины HttpProxyPlugin, реализуют класс-интерфейс HttpProxyBasePlugin.

У плагинов тоже могут быть свои плагины. Например, встроенный веб-сервер HttpWebServerPlugin является плагином непосредственно HttpProtocolHandler и реализует интерфейс HttpProtocolHandlerPlugin.

Сложность работы библиотеки обусловлена тем, что она стремится обеспечить высокую производительность. Для этого используются методы и концепции, которые мы опишем в следующих книгах.

Перейдем непосредственно к изучению кода.

Внимание! Код прокси не обязательно соответствует лучшим практикам для Python или стилю PEP8. Это код реального приложения, автор которого может нарушать соглашения. За это авторы книги ответственности не несут.

Сначала прокси-сервер читает переданные аргументы командной строки в функции main(), вызываемой из entry_point():

def main(**opts: Any) -> None:

with Proxy(sys.argv[1:], **opts):

# Ожидает секунду и обрабатывает исключение KeyboardInterrupt.

# Необходимо для того, чтобы после выхода из with не был вызван

# shutdown().

sleep_loop()

Эти аргументы передаются создаваемому в функции main() экземпляру класса proxy.Proxy. Это основной класс библиотеки:

class Proxy:

"""Proxy - контекстный менеджер, управляющий ядром библиотеки.

По умолчанию запускает proxy.core.pool.AcceptorPool` с классом-воркером

proxy.http.handler.HttpProtocolHandler.

"""

def __init__(self, input_args: Optional[List[str]] = None, **opts: Any):

# Обработка флагов. Наиболее важным здесь является то, что

# из строковой опции получается ссылка на реальный класс в параметре

# work_klass, для чего импортируется плагин, который поддерживает

# соответствующий протокол.

self.flags = FlagParser.initialize(input_args, **opts)

...

# Будет вызван из блока with.

def __enter__(self) -> 'Proxy':

self.setup()

return self

# Будет вызван при выходе из блока with.

def __exit__(self, *args: Any) -> None:

self.shutdown()

В нем создаются прослушивающие сокеты, процессы, исполняющие код обработчиков протокола, и акцепторы:

def setup(self) -> None:

# Записать PID-файл.

self._write_pid_file()

# Установить пул прослушивателей.

self.listeners = ListenerPool(flags=self.flags)

self.listeners.setup()

...

# Создать пул исполняющих процессов.

if self.remote_executors_enabled:

self.executors = ThreadlessPool(

flags=self.flags,

event_queue=event_queue,

executor_klass=RemoteFdExecutor,

)

self.executors.setup()

# Создать пул акцепторов.

self.acceptors = AcceptorPool(

# Через флаги передается класс обработчика.

# proxy.http.HttpProtocolHandler по умолчанию.

flags=self.flags,

# Пулу акцепторов передаются ранее созданные прослушивающие

# сокеты.

listeners=self.listeners,

# Очереди заданий, идентификаторы процессов и блокировки

# исполняющих процессов.

executor_queues=self.executors.work_queues if self.executors

else [],

executor_pids=self.executors.work_pids if self.executors else [],

executor_locks=self.executors.work_locks if self.executors

else [],

event_queue=event_queue,

)

# Запустить акцепторы и передать им дескрипторы прослушивающих

# сокетов.

self.acceptors.setup()

...

Для TCP экземпляр ListenerPool создает объекты класса TcpSocketListener с нужным портом. Собственно, эти объекты создают прослушивающий сокет, устанавливают опции и запускают listen():

class TcpSocketListener(BaseListener):

"""Tcp listener."""

def __init__(self, *args: Any, port: Optional[int] = None, **kwargs: Any):

# Если порт был передан, он будет использоваться.

# В ином случае будет использован порт из опции.

self.port = port

# Сюда сохраняется выделенный автоматически порт.

self._port: Optional[int] = None

super().__init__(*args, **kwargs)

На сокете функция listen() вызывается в методе класса Proxy с идентичным названием:

# Этот метод будет вызван из базового класса, если он появится

# в блоке with или явно, через его метод setup().

# Базовый класс также закроет сокет при выходе из with.

def listen(self) -> socket.socket:

sock = socket.socket(

socket.AF_INET6 if self.flags.hostname.version == 6

else socket.AF_INET,

socket.SOCK_STREAM,

)

# Взвести уже известный нам флаг сокета, который еще будет рассмотрен

# подробно.

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# Отключить алгоритм Нейгла.

sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

# s.setsockopt(socket.SOL_TCP, socket.TCP_FASTOPEN, 5)

# Получить реальный порт.

port = self.port if self.port is not None else self.flags.port

# Привязать адрес.

sock.bind((str(self.flags.hostname), port))

# Принимать соединения.

sock.listen(self.flags.backlog)

# Внутри данный прокси работает в неблокирующем режиме.

sock.setblocking(False)

# Порт, на котором реально идет прослушивание.

self._port = sock.getsockname()[1]

# Некорректное форматирование строки журналирования.

logger.info(

'Listening on %s:%s' % (self.flags.hostname, self._port),

)

return sock

Данный прокси может прослушивать на разных портах одновременно, поэтому прослушивателей может быть создано несколько.

После создания прослушивателей создаются воркеры. Например, ThreadlessPool создаст пул из процессов — объектов multiprocessing.Process и воркеров, или исполнителей, — переданного ему класса, взаимодействие с которыми будет производиться через канал multiprocessing.Pipe.

Воркер может быть экземпляром одного из классов, показанных на рис. 21.9, например RemoteFdExecutor, который принимает задачи через multiprocessing.Connection — концы одного канала, созданного Pipe. Внутри данных воркеров работает асинхронный событийный цикл.

Рис. 21.9. Исполнители в Proxy.py

Только после этого объект класса AcceptorPool создает акцепторы, передает им дескрипторы прослушивающих сокетов и запускает исполнение:

class AcceptorPool:

"""AcceptorPool создает классы proxy.core.acceptor.acceptor.Acceptor,

стараясь использовать все доступные ядра CPU.

Передает дескрипторы, из которых приходят задачи, через пайп.

Класс-обработчик берется из `flags.work_klass`.

"""

def __init__(

self,

flags: argparse.Namespace,

listeners: ListenerPool,

executor_queues: List[connection.Connection],

executor_pids: List[int],

executor_locks: List['multiprocessing.synchronize.Lock'],

event_queue: Optional['EventQueue'] = None,

) -> None:

# Через флаги передается класс обработчика.

self.flags = flags

# Файловые дескрипторы для получения новых задач.

# Пул сокетов, ожидающих соединения от клиента.

self.listeners: ListenerPool = listeners

# Доступные исполнители.

self.executor_queues: List[connection.Connection] = executor_queues

self.executor_pids: List[int] = executor_pids

...

# Список процессов акцепторов.

self.acceptors: List[Acceptor] = []

# Очереди файловых дескрипторов, служащие для разделения дескрипторов

# с процессами акцепторов.

self.fd_queues: List[connection.Connection] = []

...

def setup(self) -> None:

"""Создать и запустить акцепторы."""

self._start()

...

# Отправить дескрипторы процессам акцепторов.

for index in range(self.flags.num_acceptors):

self.fd_queues[index].send(len(self.listeners.pool))

for listener in self.listeners.pool:

fd = listener.fileno()

# Здесь используется функция из модуля multiprocess.

send_handle(

self.fd_queues[index],

fd,

self.acceptors[index].pid,

)

self.fd_queues[index].close()

...

Непосредственно запуск производится в методе _start(), который вызывает метод start() акцептора:

def _start(self) -> None:

"""Запустить процессы акцепторов."""

for acceptor_id in range(self.flags.num_acceptors):

# Управляющий канал.

work_queue = multiprocessing.Pipe()

# Создать экземпляр акцептора, передав ему один из концов

# управляющего канала.

acceptor = Acceptor(

idd=acceptor_id,

fd_queue=work_queue[1],

# Через флаги передается класс обработчика.

flags=self.flags,

lock=self.lock,

event_queue=self.event_queue,

executor_queues=self.executor_queues,

executor_pids=self.executor_pids,

executor_locks=self.executor_locks,

)

# Запуск процесса акцептора.

# Акцептор наследует multiprocessing.Process, поэтому будет

# запущен метод run() в отдельном процессе.

acceptor.start()

self.acceptors.append(acceptor)

# Добавляется очередь для обмена с акцепторами.

self.fd_queues.append(work_queue[0])

Пулу акцепторов передается класс плагина, по умолчанию имеющий значение proxy.http.HttpProtocolHandler, то есть обработчик HTTP. Но конечно, могут быть использованы реализации прокси для разных протоколов.

Главным в акцепторе является метод accept(), который вызовет соответствующий метод у всех объектов класса socket.socket, готовых выполнить accept(), не блокируя поток:

class Acceptor(multiprocessing.Process):

"""Рабочий процесс акцептора. Акцептор ожидает прихода новых задач через

переданный ему сокет, дескриптор которого получает при запуске.

Дескриптор передается через `fd_queue`.

По умолчанию акцептор будет создавать новый поток для каждой задачи.

"""

def __init__(

self,

idd: int,

fd_queue: connection.Connection,

flags: argparse.Namespace,

lock: 'multiprocessing.synchronize.Lock',

executor_queues: List[connection.Connection],

executor_pids: List[int],

executor_locks: List['multiprocessing.synchronize.Lock'],

event_queue: Optional[EventQueue] = None,

) -> None:

super().__init__()

...

# Дескрипторы, которые используются для приема новых задач.

self.socks: dict[int, socket.socket] = {}

...

def accept(

self,

events: List[Tuple[selectors.SelectorKey, int]],

) -> List[Tuple[socket.socket, Optional[HostPort]]]:

works = []

for key, mask in events:

if mask & selectors.EVENT_READ:

try:

# Вызов реального accept() для сокета.

conn, addr = self.socks[key.data].accept()

# Добавление нового сокета в список воркеров.

works.append((conn, addr or None))

except BlockingIOError:

pass

return works

...

Следующий метод вызывается из метода run() акцептора:

def _recv_and_setup_socks(self) -> None:

# Здесь принимаются дескрипторы и создаются объекты сокетов.

for _ in range(self.fd_queue.recv()):

fileno = recv_handle(self.fd_queue)

# Для создания из дескриптора используется уже известный нам

# метод.

self.socks[fileno] = socket.fromfd(

fileno,

family=self.flags.family,

type=socket.SOCK_STREAM,

)

self.fd_queue.close()

Класс обработчика HTTP:

class HttpProtocolHandler(BaseTcpServerHandler[HttpClientConnection]):

"""Обработчик HTTP, HTTPS, HTTP2, WebSocket-протокола.

Принимает клиентское соединение и делегирует его обработку экземпляру

HttpProtocolHandlerPlugin.

"""

def __init__(self, *args: Any, **kwargs: Any):

super().__init__(*args, **kwargs)

...

# Непосредственно парсер HTTP, который работает с запросом.

# Он тоже асинхронный и в процессе работы проверяет, не появились

# ли события, для чего использует select(), poll() или подобную

# функцию, за выбор которой отвечает DefaultSelector().

self.request: HttpParser = HttpParser(

httpParserTypes.REQUEST_PARSER,

enable_proxy_protocol=self.flags.enable_proxy_protocol,

)

self.selector: Optional[selectors.DefaultSelector] = None

if not self.flags.threadless:

self.selector = selectors.DefaultSelector()

# Плагин-обработчик.

# HttpProtocolHandlerPlugin — базовый класс.

self.plugin: Optional[HttpProtocolHandlerPlugin] = None

@staticmethod

def create(*args: Any) -> HttpClientConnection:

# Создать клиентское HTTP-соединение.

return HttpClientConnection(*args)

def initialize(self) -> None:

super().initialize()

if self._encryption_enabled():

self.work = HttpClientConnection(

conn=self.work.connection,

addr=self.work.addr,

)

...

Рассмотрение DefaultSelector и асинхронной обработки мы отложим до книги 2.

Обработка запросов производится следующим образом:

async def _parse_first_request(self, data: memoryview) -> bool:

# Разбор HTTP-запроса.

try:

self.request.parse(data)

except HttpProtocolException as e:

# Обработка ошибок.

...

if not self.request.is_complete:

return False

...

# Определение того, какой HTTP-обработчик применим для обработки

# данного входящего запроса.

klass = self._discover_plugin_klass(

self.request.http_handler_protocol,

)

if klass is None:

# Класс, соответствующий протоколу, не найден.

self.work.queue(BAD_REQUEST_RESPONSE_PKT)

return True

...

# Инициализация плагина.

self.plugin = self._initialize_plugin(klass)

# Обработка и получение результатов.

output = self.plugin.on_request_complete()

...

Отдельно стоит рассмотреть устройство метода shutdown() класса обработчика. В нем видно, что все замечания относительно вызова close() были учтены автором библиотеки в процессе разработки:

def shutdown(self) -> None:

try:

# Сбросить отложенный буфер только в многопоточном режиме.

# Для асинхронного режима BaseTcpServerHandler реализует

# логику must_flush_before_shutdown автоматически.

if self.selector and self.work.has_buffer():

self._flush()

# Вызвать обработчик plugin.on_client_connection_close.

if self.plugin:

self.plugin.on_client_connection_close()

conn = self.work.connection

# Если было включено шифрование, оно снимается.

if self._encryption_enabled() and \

isinstance(self.work.connection, ssl.SSLSocket):

conn = self.work.connection.unwrap()

# Отключается возможность записи в данный сокет.

conn.shutdown(socket.SHUT_WR)

logger.debug('Client connection shutdown successful')

except OSError:

pass

finally:

# Секция 4.2.2.13

# RFC 1122 "Requirements for Internet Hosts -

# Communication Layers" говорит, что close()

# приведет к немедленной отправке RST и закрытию соединения.

self.work.connection.close()

logger.debug('Client connection closed')

# Вызов приведет к вызову метода Work.shutdown(),

# который инициирует событие WORK_FINISHED.

# Это, в свою очередь, приведет к остановке

# циклов обработки событий.

super().shutdown()

Различные воркеры содержат объекты класса UpstreamConnectionPool, отвечающего за создание целого массива подключений к целевому серверу. Каждое подключение — это объект класса TcpServerConnection. Эти объекты содержат внутри сокеты для подключения к серверу.

Пул нужен, чтобы не выполнять долгие операции подключения к серверу повторно: соединения не закрываются, а многократно используются несколькими запросами.

Класс TcpServerConnection позволяет единообразно использовать обычное подключение и зашифрованный протокол HTTPS:

class TcpServerConnection(TcpConnection):

"""Буферизованное подключение к серверу."""

def __init__(self, host: str, port: int) -> None:

super().__init__(tcpConnectionTypes.SERVER)

self._conn: Optional[TcpOrTlsSocket] = None

self.addr: HostPort = (host, port)

self.closed = True

@property

def connection(self) -> TcpOrTlsSocket:

# Выполняет подключение, если оно еще не выполнено.

if self._conn is None:

raise TcpConnectionUninitializedException()

return self._conn

def connect(

self,

addr: Optional[HostPort] = None,

source_address: Optional[HostPort] = None,

) -> None:

# Здесь выполняется подключение к серверу.

self._conn = new_socket_connection(

addr or self.addr, source_address=source_address,

)

self.closed = False

Для работы по HTTPS используется метод wrap(), оборачивающий подключение в контекст TLS:

def wrap(

self,

hostname: Optional[str] = None,

ca_file: Optional[str] = None,

as_non_blocking: bool = False,

verify_mode: ssl.VerifyMode = ssl.VerifyMode.CERT_REQUIRED,

) -> None:

# Метод оборачивает подключение в контекст TLS.

# Подробнее рассмотрим его после изучения TLS.

ctx = ssl.create_default_context(

ssl.Purpose.SERVER_AUTH,

cafile=ca_file,

)

ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 \

| ssl.OP_NO_TLSv1_1

ctx.check_hostname = hostname is not None

ctx.verify_mode = verify_mode

self.connection.setblocking(True)

# Теперь дескриптор сокета будет заменен дескриптором контекста TLS.

# Весь обмен через сокет будет зашифрован.

self._conn = ctx.wrap_socket(

self.connection,

server_hostname=hostname,

)

if as_non_blocking:

self.connection.setblocking(False)

Подробнее мы рассмотрим SSL и TLS в книге 3.

Реальное же соединение создается функцией common.utils.new_socket_connection(), которая вызывается в методе TcpServerConnection.connect() выше.

В ней и вызываются методы сокета:

def new_socket_connection(

addr: HostPort,

timeout: float = DEFAULT_TIMEOUT,

source_address: Optional[HostPort] = None,

) -> socket.socket:

conn = None

try:

ip = ipaddress.ip_address(addr[0])

if ip.version == 4:

# Для IPv4 создается сокет AF_INET.

conn = socket.socket(

socket.AF_INET, socket.SOCK_STREAM, 0,

)

# Установка тайм-аута, чтобы не ожидать подключения слишком долго.

conn.settimeout(timeout)

conn.connect(addr)

else:

# Для IPv6-адресов — AF_INET6, что является известным приемом.

conn = socket.socket(

socket.AF_INET6, socket.SOCK_STREAM, 0,

)

conn.settimeout(timeout)

conn.connect((addr[0], addr[1], 0, 0))

except ValueError:

pass

if conn is not None:

return conn

# Попытка создать IPv4/IPv6-подключение, если используется двойной стек.

return socket.create_connection(addr, timeout=timeout,

source_address=source_address)

Класс TCPConnection, предок класса TcpServerConnection, является абстракцией над подключением TCP. Наследуемые от него классы реализуют метод, возвращающий подключение:

class TcpConnection(ABC):

"""Абстракция подключения сервера/клиента TCP.

Основная мотивация этого класса — обеспечить управление буфером

при чтении и записи в сокет.

"""

def __init__(self, tag: int) -> None:

# Тег нужен для того, чтобы различать клиентские и серверные

# соединения.

self.tag: str = (

'server' if tag == tcpConnectionTypes.SERVER else 'client'

)

self.buffer: List[memoryview] = []

self.closed: bool = False

self._reusable: bool = False

self._num_buffer = 0

@property

@abstractmethod

def connection(self) -> TcpOrTlsSocket:

"""

Необходимо переопределить данный метод и вернуть подключенный сокет.

Это делается в потомке — классе TcpServerConnection, рассмотренном

выше.

А в классе HttpClientConnection, который является потомком

ClientConnection, данный метод только проверяет, что уже подключенный

сокет был передан объекту в конструкторе.

"""

raise TcpConnectionUninitializedException()

Кроме того, в нем производится обмен данными и реализованы необходимые для этого методы, вызывающие методы реального соединения:

def send(self, data: Union[memoryview, bytes]) -> int:

"""Отправка данных"""

# Просто вызов socket.send().

# Исключения BrokenPipeError должен обработать пользователь.

return self.connection.send(data)

def recv(

self, buffer_size: int = DEFAULT_BUFFER_SIZE,

) -> Optional[memoryview]:

"""Прием данных"""

# Вызов socket.recv().

# Исключения socket.error должен обработать пользователь.

data: bytes = self.connection.recv(buffer_size)

if len(data) == 0:

return None

# Снова ошибка форматирования строк протокола.

logger.debug('received %d bytes from %s' % (len(data), self.tag),)

return memoryview(data)

В классе имеется метод закрытия соединения и методы работы с буфером, позволяющие добавлять в него данные как в очередь и запускать отправку:

def close(self) -> bool:

"""Закрыть сокет"""

if not self.closed:

self.connection.close()

self.closed = True

return self.closed

def has_buffer(self) -> bool:

"""Вернет истину, если у подключения есть неотправленные данные"""

return self._num_buffer != 0

def queue(self, mv: memoryview) -> None:

"""В буфер подключения для отправки добавляется новая порция данных"""

self.buffer.append(mv)

self._num_buffer += 1

def flush(self, max_send_size: Optional[int] = None) -> int:

"""Отправить данные, сохраненные в буфере"""

if not self.has_buffer():

return 0

mv = self.buffer[0]

max_send_size = max_send_size or DEFAULT_MAX_SEND_SIZE

# Попытка отправить часть данных из первого буфера.

sent: int = self.send(mv[:max_send_size])

if sent == len(mv):

# Если все данные были отправлены, этот буфер возможно удалить.

self.buffer.pop(0)

self._num_buffer -= 1

else:

# Иначе указатель на данные в буфере смещается.

# Копирования данных тут не произойдет.

self.buffer[0] = mv[sent:]

del mv

logger.debug('flushed %d bytes to %s' % (sent, self.tag))

return sent

а также некоторые служебные методы, которые облегчают использование соединения с пулами воркеров:

def is_reusable(self) -> bool:

# Возможно ли переиспользовать данное соединение?

return self._reusable

def mark_inuse(self) -> None:

# Тот, кому было выдано соединение, может пометить его

# неиспользуемым.

self._reusable = False

После рассмотрения объекта соединения перейдем к изучению функционирования пула исходящих соединений.

Объект класса UpstreamConnectionPool поддерживает отдельный пул соединений для каждого сервера. По сути, этот класс — пул пулов. Внутренняя структура данных хранит ссылки на объекты соединения, которыми этот пул владеет или которые заимствовал. Заимствованные соединения не переиспользуются и не проверяются на обрыв.

Для переиспользуемых соединений пул ожидает событие чтения, наступление которого говорит о том, что соединение разорвано. Это может произойти, если пул открыл соединение к серверу, оно долго не использовалось и достигло ограничения по времени ожидания.

Когда заимствованное соединение возвращается обратно в пул, оно снова помечается как повторно используемое. Но если возвращаемое соединение уже закрыто, оно удаляется из внутренней структуры данных.

Класс и метод получения соединения:

class UpstreamConnectionPool(Work[TcpServerConnection]):

"""

Управляет пулом соединений с вышестоящими серверами.

"""

def __init__(self) -> None:

# Отображение дескриптора соединения на объект.

self.connections: Dict[int, TcpServerConnection] = {}

# Отображение адреса соединения на объект.

self.pools: Dict[HostPort, Set[TcpServerConnection]] = {}

@staticmethod

def create(*args: Any) -> TcpServerConnection:

# Просто создается новый объект соединения с сервером.

return TcpServerConnection(*args)

def acquire(self, addr: HostPort) -> Tuple[bool, TcpServerConnection]:

"""Взять переиспользуемое соединение из пула.

Если таких соединений нет, создать новое соединение

и вернуть его."""

created, conn = False, None

if addr in self.pools:

# Для этого адреса может быть несколько соединений.

for old_conn in self.pools[addr]:

if old_conn.is_reusable():

# Найдено первое, которое можно переиспользовать.

conn = old_conn

break

# Соединение не было найдено, создать и добавить новое.

if conn is None:

created, conn = True, self.add(addr)

# Выданное соединение нужно пометить как используемое.

conn.mark_inuse()

return created, conn

Метод закрытия соединения и метод возврата его в пул:

def release(self, conn: TcpServerConnection) -> None:

"""

Закрыть ранее установленное соединение.

Это приведет к отключению и закрытию сокета, а также удалению

соединения из пула.

"""

self._remove(conn.connection.fileno())

def retain(self, conn: TcpServerConnection) -> None:

"""Сохранить ранее полученное соединение в пуле для повторного

использования."""

conn.reset()

Обработка событий:

async def get_events(self) -> SelectableEvents:

"""

Установить флаг события чтения для всех переиспользуемых соединений

в пуле.

Необходимо для возможности обнаруживать изменение состояния

данного соединения. Например, его обрыв или закрытие сервером.

"""

events = {}

for connections in self.pools.values():

for conn in connections:

# Установка флага.

if conn.is_reusable():

events[conn.connection.fileno()] = selectors.EVENT_READ

return events

async def handle_events(self, readables: Readables,

_writables: Writables) -> bool:

"""

Удалить многократно используемое соединение из пула.

Когда пул является владельцем соединения, событие чтения от сервера не

ожидается.

Возникновение события означает, что либо восходящий поток

закрыл соединение, либо соединение каким-то образом достигло

недопустимого состояния.

"""

for fileno in readables:

# Удалить соединение.

self._remove(fileno)

return False

Реальное добавление и удаление соединений производится в следующих методах:

def add(self, addr: HostPort) -> TcpServerConnection:

"""

Создать новое соединение, подключить и добавить в пул.

Вместо этого метода клиент пула должен использовать метод aquire().

"""

new_conn = self.create(addr[0], addr[1])

new_conn.connect()

self._add(new_conn)

return new_conn

def _add(self, conn: TcpServerConnection) -> None:

"""Добавить новое соединение во внутреннюю структуру данных."""

if conn.addr not in self.pools:

self.pools[conn.addr] = set()

conn._reusable = True

self.pools[conn.addr].add(conn)

self.connections[conn.connection.fileno()] = conn

def _remove(self, fileno: int) -> None:

"""Удалить соединение из внутренней структуры данных."""

conn = self.connections[fileno]

try:

conn.connection.shutdown(socket.SHUT_WR)

except OSError:

pass

conn.close()

self.pools[conn.addr].remove(conn)

del self.connections[fileno]

В целом работу сетевой части библиотеки должно быть несложно понять. Сама библиотека имеет множество тонких нюансов, и мы расмотрели далеко не все из них. Это нормально для любого реального и достаточно сложного проекта. К некоторым деталям этой библиотеки мы еще будем возвращаться.

Отличие проксирования от VPN

Помимо прокси существуют и другие посредники, выполняющие передачу и, возможно, обработку трафика. Например, VPN, которые выполняют инкапсуляцию.

Главное отличие между ними концептуальное. Прокси — посредник между ресурсами, заместитель, выполняющий запрос от своего имени. VPN же скрывает передаваемые данные.

Таким образом:

• Проксирование — это замещение примерно равноправным агентом, интерпретация и дальнейшая передача запроса. Сервер-посредник делает запрос вместо приложения.

• Инкапсуляция — это сокрытие. Например, упаковка в пакеты другого протокола и дальнейшая транспортировка. Точка входа для VPN отправляет данные приложения в некоторую подсеть.

Соответственно, VPN — это Virtual Private Network, то есть сеть. Схема ее работы показана на рис. 21.10. VPN-сервисы позволяют объединять сети в одну, используя виртуальные защищенные каналы поверх физических или других виртуальных. Задача VPN — передавать запросы между разными сетями либо обеспечивать единое сетевое пространство.

Рис. 21.10. Схема работы VPN

VPN-сервис перенаправляет запросы в связанную подсеть, а прокси направляет запросы на конкретные узлы. Поэтому VPN работает на канальном, транспортном или сетевом уровнях, а прокси обычно на прикладном либо транспортном уровне и иногда на сессионном, как в SOCKS.

VPN перехватывает трафик, шифрует его, отправляет в нужную подсеть и расшифровывает. Изменением трафика VPN обычно не занимается. Поэтому неправильно сконфигурированный VPN может быть причиной отсутствия доступа к сети. Неправильно же сконфигурированный прокси может быть причиной как отсутствия доступа, так и искажения трафика.

С точки зрения реализации типичный вариант реализации прокси — это прослушивающий на определенном порту транспортного протокола сервер. А типичный вариант реализации VPN — это виртуальный сетевой интерфейс, через который можно подключиться к сети.

VPN относится скорее к части канала и к ведению сетевого администратора, и потому здесь мы его не рассматриваем.

В главе 8 мы рассматривали опцию SO_NO_CHECK. Она может быть полезна для реализации VPN. Каналы, предоставляемые ею, уже покрыты различными контрольными суммами, поэтому для уменьшения накладных расходов имеет смысл отключать расчет контрольных сумм заголовков внутренних пакетов.

Необходимо понимать, что VPN использует для перенаправления трафика те же самые механизмы, что и прокси-серверы. Например, Tor предоставляет SOCKS-прокси, чтобы обеспечить работу из любого браузера через Tor-сеть, а некоторые VPN перенаправляют трафик, добавляя правила брандмауэра.

Резюме

Любые данные создаются производителем и отправляются потребителю через каналы связи.

На разных уровнях в канале связи над передаваемыми данными могут производиться разные операции.

Мы рассмотрели прикладной уровень, который позволяет работать с данными на уровне протоколов высокого уровня, таких как HTTP. На этом уровне доступны подробные метаданные: кто, откуда, как и какие данные получил, а также обычно доступны сами данные. На нем работают прокси-серверы, зависимые от конкретных протоколов, и DLP-системы, предотвращающие утечки конфиденциальных данных организации.

Прокси-сервер является посредником, который получает запросы от клиентов, перенаправляет их к целевым серверам и возвращает ответы. Обычные пользователи используют прокси для доступа к ресурсам или для сокрытия своего IP-адреса от удаленных абонентов.

Компании могут использовать прокси для ограничения доступа к определенным ресурсам по адресам, разрешения доступа только для авторизованных пользователей, фильтрации содержимого, кэширования и сжатия данных для повышения скорости ответа и экономии трафика, а также для балансировки нагрузки при использовании нескольких каналов или маршрутизаторов.

Некоторые протоколы, такие как HTTP, начиная с версии 1.1, предполагают возможность наличия прокси и содержат возможности для поддержки их работы, что позволяет, например, объединять прокси в цепочки.

Существуют также протоколы исключительно для проксирования, например семейство протоколов SOCKS или прокси-протокол, которые позволяют осуществлять проксирование на транспортном уровне.

Проксирование может производиться в нескольких режимах: когда прокси явно задан в приложении или может использоваться прозрачное проксирование — автоматическое направление трафика любого приложения через прокси-сервер.

Прокси может использоваться не только для доступа к внешним ресурсам, но и для предоставления доступа к ресурсам серверов. В последнем случае прокси называется обратным, или реверс-прокси. Обратные прокси широко применяются для балансировки нагрузки, контроля заголовков, передаваемых серверам, внедрения внешней авторизации для доступа к сервисам и т.д.

Так как прокси работает на прикладном или транспортном уровне, уровни ниже остаются им не затронуты. Эту «нишу» занимают VPN, которые обычно реализуются на сеансовом, сетевом или даже канальном уровне.

Прокси, как правило, предоставляет приложениям сервис-посредник, а VPN — сетевой интерфейс. Но и у прокси и у VPN программная реализация может быть очень схожей. Главное отличие между ними принципиальное. В отличие от прокси, VPN-сервисы выполняют инкапсуляцию, скрывают данные и предоставляют общую виртуальную сеть поверх нескольких физических сетей и даже поверх сети интернет. А прокси — это сервер-посредник.

Вопросы и задания

1. Что такое прокси-сервер и как он функционирует?

2. Какие преимущества дает использование прокси-сервера для пользователя?

3. Может ли прокси-сервер обеспечить безопасность сетевого трафика и защиту от внешних угроз, и если да, то как?

4. Каковы сценарии использования прокси-сервера в корпоративной среде и какие преимущества он может предоставить для бизнеса?

5. Как работают HTTP-прокси?

6. Чем различаются прямой и обратный прокси, где используется тот и другой?

7. Зачем нужен SOCKS-протокол?

8. Что такое прокси-протокол и для чего он нужен?

9. Что такое прозрачное проксирование и в чем его отличие от непрозрачного?

10. Зачем нужна опция IP_TRANSPARENT и как она работает?

11. Каковы требования для работы опции IP_TRANSPARENT?

12. В чем принципиальное различие проксирования и VPN?

13. В чем заключается механизм перенаправления трафика через VPN с добавлением правил брандмауэра?

14. Можно ли использовать VPN и прокси-серверы для одних и тех же целей? Если да, приведите пример.

15. Добавьте в пример прокси на C++ разбор IP-адресов назначения. Если IP-адрес назначения не равен локальной подсети, например 192.168.0.X, проксирование производится, в противном случае — нет.

158 https://github.com/darkk/redsocks

159 https://www.ufasoft.com/socks/

160 https://win2socks.com/

161 https://nginx.org/en/docs/stream/ngx_stream_core_module.html

162 https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html

163 https://proxomitron.info/index.html

164 https://privoxy.org/

165 https://github.com/squid-cache/squid

166 https://github.com/tinyproxy/tinyproxy

167 https://github.com/tinyproxy/tinyproxy/blob/master/src/transparent-proxy.c

168 https://mitmproxy.org/

169 https://www.telerik.com/fiddler

170 https://www.httpdebugger.com/

171 https://www.charlesproxy.com/

172 https://github.com/chobits/ngx_http_proxy_connect_module

173 https://pypi.org/project/proxy.py/

Глава 22. Перехват и захват трафика

Цель — справедливость, метод — прозрачность. Важно не путать цель и метод.

Джулиан Ассанж, 2011

Введение

Чтобы увидеть, что происходит на интерфейсе адаптера, подключенного к выбранному каналу, используют анализаторы трафика, или снифферы, от английского sniff — «нюхать, чуять». При разработке сетевых приложений они используются достаточно часто.

В этой главе мы изучим перехват трафика и рассмотрим несколько его вариантов, а также принципы работы снифферов. Научимся переводить адаптер в неразборчивый режим, узнаем некоторые особенности перехвата данных, а также методы фильтрации и интерпретации информации, получаемой в ходе мониторинга.

Обсудим структуры данных и константы для IPv4. Для закрепления напишем собственную реализацию простого сниффера на С++ и Python. Затем изучим широко распространенный формат PCAP, используемый для хранения сетевых дампов, и реализуем сниффер, использующий библиотеку libpcap, которая поддерживает данный формат. Посмотрим, какие продукты существуют для Unix-подобных ОС и для ОС Windows. И в конце разберем библиотеки и пакеты, которые облегчают работу с перехватом и захваченными данными.

Перехват трафика

Перехватчики бывают программными и аппаратными. Аппаратные снифферы близко пересекаются с анализаторами шины174, и часто их используют для диагностики сети, поиска ошибок в физических каналах или для сбора данных с целью шпионажа. Программисты с такими перехватчиками, как правило, не работают.

В целом способы перехвата трафика можно разделить на следующие группы:

• Прослушивание сетевого интерфейса.

• Подключение в разрыв канала. Кроме перехвата, дает возможность изменения данных.

• Ответвление трафика. Например, полностью аппаратно: через зеркальный порт на маршрутизаторе или оптический сплиттер на оптоволокне. Либо программными средствами через брандмауэр.

• Перехват системных вызовов. Реализуется на уровне приложения или всей системы. В последнем случае используется драйвер или модуль ядра. Перехватывать вызовы, не выходя за прикладной уровень, также можно на уровне системы, хотя для этого и требуются административные права. Этот вариант перехвата для Linux мы рассмотрим далее.

• Анализ побочных электромагнитных излучений. Данные снимаются направленными антеннами, индукционными датчиками или более чувствительным оборудованием, которое может регистрировать электромагнитные поля очень низкой напряженности, как сверхпроводящие квантовые интерферометры.

• Атака на канальном или сетевом уровне, приводящая к перенаправлению трафика на сниффер. Полученный трафик анализируется либо искажается, после чего перенаправляется на адрес, куда он был направлен изначально.

Для получения данных из сети программному снифферу нужен сетевой адаптер. В то же время анализирующий блок аппаратных снифферов обычно реализован программно. Поэтому чаще всего сниффер — это программно-аппаратный комплекс.

В качестве кросс-платформенного решения часто используют Wireshark. В Windows распространены pktmon и Network Monitor, в Unix-подобных ОС — утилита tcpdump.

Неразборчивый режим

Адаптеры, в том числе Ethernet и Wi-Fi, принимают только кадры, в которых прописаны их MAC-адреса или широковещательные адреса. Остальные кадры отбрасываются на аппаратном уровне. Иными словами, адаптер фильтрует данные на канальном уровне. В случае Ethernet, например, он выполняет фильтрацию по MAC-адресу. Исключение — зеркальный порт маршрутизатора, дублирующий все пакеты.

Обычно сниффер может получать лишь пакеты, предназначенные данному адаптеру. Работа в таком режиме может быть полезна для отладки и поиска различных проблем, в основном с конкретным узлом.

Для построения более полной картины сети желательно, чтобы сниффер мог получать все данные из той общей среды передачи, в которую подключен адаптер:

• из текущего коллизионного домена, например из общей шины или радиоэфира;

• из маркерных или кольцевых сетей;

• с порта зеркалирования маршрутизатора

и других сред. Чтобы сниффер захватывал все пакеты, адаптер нужно перевести в «неразборчивый режим», или promiscuous mode. При этом отключается аппаратная фильтрация и становится возможен захват всех кадров.

Для Wi-Fi-адаптера интерфейс переводится в режим мониторинга, или RFMON175, для чего создается отдельный интерфейс мониторинга.

Мониторинговый режим Wi-Fi

На самом деле у Wi-Fi-адаптеров тоже есть неразборчивый режим, и вы можете в этом убедиться, попробовав его включить.

Режим мониторинга отличается от неразборчивого режима следующим:

• Позволяет беспроводному адаптеру захватывать пакеты без связи с точкой доступа или сетью. Что желательно, поскольку в этом случае не передаются никакие пакеты.

• Отключает контроль значений CRC адаптером для захваченных пакетов. Это позволяет видеть даже поврежденные данные.

В неразборчивом режиме можно просматривать только пакеты беспроводной сети, в которую адаптер подключен и, соответственно, аутентифицирован в точке доступа к этой сети.

В режиме мониторинга адаптер принимает все пакеты. Но неразборчивый режим поддерживают большинство драйверов беспроводных адаптеров, а режим мониторинга — далеко не все.

Для работы с Wi-Fi-адаптерами в Unix-подобных системах стандартом де-факто является пакет Aircrack-ng, состоящий из нескольких консольных утилит. Также весьма известен сетевой анализатор Kismet, который в MacOS называется Kismac. Они позволяют включать данные режимы.

Рассмотрим, как перевести в неразборчивый режим адаптер Ethernet. В Linux это делается с помощью следующего кода:

extern "C"

{

#include <sys/ioctl.h>

#include <sys/socket.h>

// Для IF_PROMISC.

#include <linux/if.h>

// Для ETH_P_ALL/ETH_P_IP.

#include <netinet/if_ether.h>

}

...

bool enable_promisc = true;

#if defined(WIN32)

// Переносимый вариант создания.

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_IP);

#else

// Создать "сырой" сокет, не привязанный к протоколу.

// Он будет принимать все протоколы, так как установлен тип ETH_P_ALL.

// Вариант для Linux.

int sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));

#endif

// Поля данной структуры:

//   — Имя сетевого интерфейса.

//   — Флаги.

struct ifreq ifr = {0};

std::copy(if_name.begin(), if_name.end(), ifr.ifr_name);

...

// Получить индекс сетевого интерфейса, с которым связан данный сокет.

if (-1 == ioctl(sock, SIOCGIFINDEX, &ifr))

{

// Не удалось найти интерфейс.

}

// Получить флаги сокета.

if (-1 == ioctl(sock, SIOCGIFFLAGS, &ifr))

{

// Не удалось получить флаги.

}

// Обновить флаги.

if (enable_promisc) ifr.ifr_flags |= IFF_PROMISC;

else ifr.ifr_flags &= ~IFF_PROMISC;

// Установить новое значение флага IFF_PROMISC.

if (-1 == ioctl(sock, SIOCSIFFLAGS, &ifr))

{

std::cerr << "Unable to set promisc mode!" << std::endl;

}

Сначала был создан raw-сокет, который будет получать все кадры, на что указывает параметр ETH_P_ALL.

ETH_P_ константы — это EtherType, то есть значения кода протокола, которые могут быть установлены в поле кадра Ethernet, за исключением ETH_P_ALL, под который попадает любой протокол.

Другие «популярные» коды — ETH_P_IP для IPv4 и ETH_P_IPV6 для IPv6.

После этого код получил флаги, установленные на интерфейс. Флаг IFF_PROMISC был сброшен или установлен, затем применено слово «флагов». Все требуемые для этого API рассматривались в предыдущих главах.

Внимание! Здесь для Linux был использован непереносимый тип AF_PACKET, который нужен для отправки приложению необработанных данных канального уровня. В обоих случаях можно создавать обычные raw-сокеты, но обработка потока данных будет отличаться. Кроме того, можно использовать обычный дейтаграммный сокет SOCK_DGRAM.

Интерфейс переводится в неразборчивый режим через вызов ioctl(), устанавливающий флаг IFF_PROMISC.

Видим, что интерфейс действительно был переведен в неразборчивый режим:

➭ ip link show eno2

2: eno2: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN mode DEFAULT group default qlen 1000

link/ether e4:54:e8:38:26:9c brd ff:ff:ff:ff:ff:ff

altname enp0s31f6

➭ sudo build/bin/b01-ch22-promisc-switcher eno2 e

➭ ip link show eno2

2: eno2: <NO-CARRIER,BROADCAST,MULTICAST,PROMISC,UP> mtu 1500 qdisc fq_codel state DOWN mode DEFAULT group default qlen 1000

link/ether e4:54:e8:38:26:9c brd ff:ff:ff:ff:ff:ff

altname enp0s31f6

В ОС Windows вместо установки данного флага используется вызов SIO_RCVALL:

#include <mstcpip.h>

#include <iphlpapi.h>

// Создать сырой сокет.

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_IP);

// Необходимо связать IP-адрес, по которому Windows определит сетевой

// интерфейс.

if (-1 == bind(sock_, reinterpret_cast<const struct sockaddr*>(&iface_addr),

sizeof(iface_addr)))

{

...

}

...

DWORD dwValue = RCVALL_ON;

DWORD dwBytesReturned = 0;

// Выполнить системный вызов.

if (SOCKET_ERROR == WSAIoctl(sock, SIO_RCVALL, &dwValue, sizeof(dwValue),

nullptr, 0, &dwBytesReturned, nullptr, nullptr))

{

// Ошибка.

}

То же самое в Python:

if sys.platform == 'win32':

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:

# В Windows интерфейс будет идентифицирован по связанному IP-адресу.

sock.bind(('192.158.5.3', 0))

sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)

Увы, для Linux и других Unix-подобных систем в версии Python по крайней мере 3.10 и ранее в модуле socket нужные константы отсутствуют.

Но можно добавить их самостоятельно, подсмотрев значения в /usr/include/linux/if.h или /usr/include/net/if.h.

Реализуем небольшой класс, выполняющий установку и сброс данного флага внутри блока with.

Сначала определим нужные константы и структуру:

import ctypes

from fcntl import ioctl

import socket

import sys

# С точки зрения C-функций и оберток над этими функциями данный класс

# повторяет C-структуру ifreq.

class InterfacePromiscSwitcher(ctypes.Structure):

# Этих констант нет в модуле socket, поэтому добавим их явно.

IFF_PROMISC = 0x100

SIOCGIFFLAGS = 0x8913

SIOCSIFFLAGS = 0x8914

# А это поля "структуры" ifreq.

_fields_ = [('ifr_ifrn', ctypes.c_char * 16),

('ifr_flags', ctypes.c_short)]

Добавим конструктор так, чтобы класс мог работать как с уже существующим сокетом, так и автономно:

def __init__(self, if_name: str, *args, **kw):

super().__init__(*args, **kw)

# Можно переиспользовать уже созданный ранее сокет.

# Или создать новый.

self._sock = (

kw['socket'] if 'socket' in kw

else socket.socket(socket.AF_INET, socket.SOCK_DGRAM

)

self.ifr_ifrn = if_name.encode()

@property

def socket(self) -> socket.socket | None:

return self._sock

Универсальные методы для установки неразборчивого режима и для его снятия:

def set_promisc(self):

# В API флаги интерфейса — параметр сокета.

# Поэтому в классическом варианте нужно устанавливать

# неразборчивый режим через дескриптор сокета.

sock = self._sock

if 'win32' == sys.platform:  # Windows-блок.

sock.bind((self.ifr_ifrn, 0))

sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)

else:

# Блок Unix-like.

# Получить флаги.

ioctl(sock.fileno(), self.SIOCGIFFLAGS, self)

# Включить флаг PROMISC.

self.ifr_flags |= self.IFF_PROMISC

# Установить флаги.

ioctl(sock.fileno(), self.SIOCSIFFLAGS, self)

def unset_promisc(self, close_socket: bool = True):

if 'win32' == sys.platform:

self._sock.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF)

else:

# Сбросить флаг PROMISC.

self.ifr_flags &= ~self.IFF_PROMISC

# Установить флаги.

ioctl(self._sock.fileno(), self.SIOCSIFFLAGS, self)

if close_socket:

self._sock.close()

Добавим специальные методы для работы с блоком with:

def __enter__(self):

self.set_promisc()

return self

def __exit__(self, *args):

self.unset_promisc()

И наконец, оформим модуль как утилиту, если он вызван из консоли:

if '__main__' == __name__:

# Проверим работу класса.

from sys import argv

import subprocess

if len(argv) != 2:

print(f'{argv[0]} <interface name>')

sys.exit(1)

interface_name = argv[1]

with InterfacePromiscSwitcher(interface_name) as ifreq:

print(f'Promiscuous mode for the "{interface_name}" enabled:')

print(subprocess.check_output(['ip', 'a', 'show', interface_name],

encoding='utf8'))

print(f'Promiscuous mode for the "{interface_name}" disabled:')

print(subprocess.check_output(['ip', 'a', 'show', interface_name],

encoding='utf8'))

Понятно, что вызов команды ip show будет работать только на Linux. Посмотрим на результат:

➭ sudo src/book01/ch22/python/promisc_switcher/promisc_switcher.py wlo1

Promiscuous mode for the "wlo1" enabled:

3: wlo1: <BROADCAST,MULTICAST,PROMISC,UP,LOWER_UP> mtu 1500 qdisc noqueue

state UP group default qlen 1000

...

Promiscuous mode for the "wlo1" disabled:

3: wlo1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000

При выходе из блока with не устанавливается фраг неразборчивого режима, если ранее он был установлен на адаптере. Такой код считается не вполне верным: вдруг на адаптере работает несколько снифферов?

Но с другой стороны, работа адаптера в таком режиме и работа нескольких снифферов на этом адаптере одновременно — ситуация нетипичная. А некорректное завершение программы, в результате которого не был вызван специальный метод __exit__(), — ситуация возможная.

Хотя блок with, по сути, эквивалентен блоку try/finally, процесс может быть принудительно завершен и метод не вызовется. Поэтому флаг может остаться установленным и не сброшенным последующими вызовами программы. Данный флаг на всякий случай стоит сбрасывать всегда.

Тем не менее в классе имеются методы, позволяющие устанавливать и сбрасывать режим, когда требуется.

Альтернатива неразборчивому режиму

Можно увидеть, что при запуске в Linux Wireshark или tcpdump утилиты не показывают факт нахождения интерфейса в promisc-режиме.

Wireshark и tcpdump используют libpcap, который задействует другой механизм установки интерфейса в promisc-режим176. Этот режим применим только для сокетов AF_PACKET.

При использовании этого механизма на интерфейс не устанавливается флаг неразборчивого режима, поэтому команды, подобные ip addr либо ifconfig, его не отображают.

В libpcap использование данного режима выглядит следующим образом177:

if (!is_any_device && handle->opt.promisc)

{

memset(&mr, 0, sizeof(mr));

mr.mr_ifindex = handlep->ifindex;

mr.mr_type    = PACKET_MR_PROMISC;

if (setsockopt(sock_fd, SOL_PACKET, PACKET_ADD_MEMBERSHIP,

&mr, sizeof(mr)) == -1)

{

pcap_fmt_errmsg_for_errno(handle->errbuf,

PCAP_ERRBUF_SIZE, errno, "setsockopt (PACKET_ADD_MEMBERSHIP)");

close(sock_fd);

return PCAP_ERROR;

}

}

Плюсом механизма является то, что promisc-режим будет выключен автоматически, когда закроется сокет, использованный для его включения.

Подробнее см. man 7 packet178.

Реализация простого сниффера

Когда интерфейс переведен в неразборчивый режим, для захвата пакетов можно использовать открытый сокет. Содержимое пакетов обычно выводится пользователю в консоль или в графический интерфейс либо записывается в файл.

Стандартом де-факто на файлы, содержащие данные из сети, является PCAP — Packet Capture, содержащий общий заголовок и массив захваченных пакетов, каждый из которых предварен своим заголовком. Этот стандарт описывает распространенный формат, поддерживаемый различными библиотеками. Он представлен на рис. 22.1.

Рис. 22.1. Формат PCAP

В примере мы будем сохранять данные в PCAP-формате, но без использования библиотеки PCAP, и поэтому сначала определим структуры PCAP-данных.

Заголовок PCAP-файла:

constexpr auto PCAP_VERSION_MAJOR = 2;

constexpr auto PCAP_VERSION_MINOR = 4;

constexpr auto DLT_EN10MB = 1;

// Полный размер буфера пакета.

constexpr auto BUFFER_SIZE_PKT = 256 * 256 — 1;

struct pcap_timeval

{

// UNIX time_t.

int32_t tv_sec;

// Смещение в микросекундах от tv_sec.

int32_t tv_usec;

};

// Заголовок PCAP-файла.

struct pcap_file_header

{

// "Магическое число".

const uint32_t magic = 0xa1b2c3d4;

// Главная версия.

const uint16_t version_major = PCAP_VERSION_MAJOR;

// Минорная версия.

const uint16_t version_minor = PCAP_VERSION_MINOR;

// Локальная временная зона для коррекции времени относительно GMT.

int32_t thiszone = 0;

// Точность временной метки.

uint32_t sigfigs = 0;

// Максимальная длина захватываемых пакетов в октетах.

uint32_t snaplen = BUFFER_SIZE_PKT;

// Тип соединения.

uint32_t linktype = DLT_EN10MB;

};

Заголовок каждого пакета, содержащегося в PCAP-файле, а также некоторые константы, относящиеся к нему:

struct pcap_sf_pkthdr

{

pcap_timeval ts;

// Количество октетов пакета, сохраненных в файле.

uint32_t caplen;

// Реальная длина пакета.

uint32_t len;

};

// Размер PCAP-заголовка пакета.

constexpr auto BUFFER_SIZE_HDR = sizeof(pcap_sf_pkthdr);

// Размер Ethernet-заголовка.

constexpr auto BUFFER_SIZE_ETH = 14;

// Размер IP-пакета.

constexpr auto BUFFER_SIZE_IP = BUFFER_SIZE_PKT — BUFFER_SIZE_ETH;

// Смещение Ethernet-заголовка в буфере: сразу за PCAP-заголовком.

constexpr auto BUFFER_OFFSET_ETH = sizeof(pcap_sf_pkthdr);

// Смещение IP-заголовка в буфере.

constexpr auto BUFFER_OFFSET_IP = BUFFER_OFFSET_ETH + BUFFER_SIZE_ETH;

#if defined(WIN32)

// Для ОС Windows Ethernet-заголовок отсутствует.

constexpr auto BUFFER_WRITE_OFFSET = BUFFER_OFFSET_IP;

constexpr auto BUFFER_ADD_HEADER_SIZE = BUFFER_SIZE_ETH;

#else

constexpr auto BUFFER_WRITE_OFFSET = BUFFER_OFFSET_ETH;

constexpr auto BUFFER_ADD_HEADER_SIZE = BUFFER_SIZE_ETH;

#endif

Позже в главе мы рассмотрим библиотеки, в которых определены все эти константы и структуры. Например, в библиотеке libpcap они содержатся в заголовочном файле pcap.h. А сейчас воспользуемся ими для записи в PCAP-файл данных, полученных из сети.

В ОС Linux будем использовать сокет типа AF_PACKET и набор функций для получения данных интерфейса:

ifreq get_ifr(const std::string& if_name, int sock)

{

ifreq ifr = {0};

std::copy(if_name.begin(), if_name.end(), ifr.ifr_name);

// Получить данные сетевого интерфейса по его имени.

if (-1 == ioctl(sock, SIOCGIFINDEX, &ifr))

{

throw std::system_error(errno, std::system_category(),

std::string("Unable to find interface ") +

if_name);

}

return ifr;

}

// Вернуть индекс сетевого интерфейса.

int get_if_index(const std::string& if_name, int sock)

{

const auto if_index = get_ifr(if_name, sock).ifr_ifindex;

std::cout << "Device index = " << if_index << std::endl;

return if_index;

}

// Получить адрес для привязки к интерфейсу.

auto get_if_address(const std::string& if_name, int sock)

{

// Структура адреса интерфейса.

sockaddr_ll iface_addr =

{

.sll_family = AF_PACKET, .sll_protocol = htons(ETH_P_IP),

.sll_ifindex = get_if_index(if_name, sock),

};

return iface_addr;

}

В ОС Windows для выполнения этой же задачи реализована одна функция:

auto get_if_address(const std::string& if_name, int sock)

{

sockaddr_in sa = {.sin_family = PF_INET, .sin_port = 0};

inet_pton(AF_INET, if_name.c_str(), &sa.sin_addr);

return sa;

}

Основную функциональность сниффера мы реализуем в классе Sniffer. В его конструкторе создадим новый сокет:

#if defined(WIN32)

sock_(AF_INET, SOCK_RAW, IPPROTO_IP),

#else

sock_(AF_PACKET, SOCK_RAW, htons(ETH_P_IP)),

#endif

Одинаковый сокет использовать нельзя — нам требуется, чтобы в обеих системах формат кадра был схож: Ethernet с заголовком IP.

Иногда полезно воспользоваться такой возможностью raw-сокетов, как получение данных только определенного протокола. В этом случае вместо ETH_P_ALL необходимо использовать конкретный протокол, а вместо AF_INET — требуемое семейство адресов.

Например, для захвата TCP-пакетов требуется использовать следующие параметры:

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);

Далее переведем интерфейс в неразборчивый режим, используя уже рассмотренную функцию, и привяжем его к адаптеру. После чего в PCAP-файл запишем общий заголовок.

Все это делается в методе init():

bool Sniffer::init()

{

// Для Windows адрес должен быть привязан до переключения в promisc-режим.

if (!bind_socket() || !switch_promisc(true) || !write_pcap_header())

return false;

initialized_ = true;

return true;

}

Метод привязки сокета к интерфейсу выполняет привязку, используя функцию bind(), так как это универсальный вариант:

bool Sniffer::bind_socket()

{

if (const size_t len = if_name_.size(); IFNAMSIZ <= len)

{

std::cerr << "Too long interface name!" << std::endl;

return false;

}

// Получить адрес интерфейса.

const auto iface_addr = get_if_address(if_name_, sock_);

// Привязать сокет к адресу.

if (-1 == bind(sock_,

reinterpret_cast<const sockaddr*>(&iface_addr), sizeof(iface_addr)))

{

std::cerr

<< "bind() failed: "

<< sock_wrap_.get_last_error_string() << "." << std::endl;

return false;

}

return true;

}

В данном случае установка опции SO_BINDTODEVICE не обязательна.

По крайней мере в Linux вместо передачи адреса вполне работает передача обычной строки, которая содержит название интерфейса, что продемонстрировано ниже в коде сниффера на Python.

После связывания адреса можно запускать цикл захвата пакетов в методе Sniffer::capture():

bool Sniffer::capture()

{

// Первые 14 байт — фейковый Ethernet-заголовок под протокол IPv4.

std::array<char, BUFFER_SIZE_HDR + BUFFER_SIZE_PKT> buffer;

// 0x08 — тип IP в кадре Ethernet. По смещению = 12.

buffer[BUFFER_OFFSET_ETH + ethernet_proto_type_offset] = 0x08;

pcap_sf_pkthdr* pkt = reinterpret_cast<pcap_sf_pkthdr*>(buffer.data());

// Прочитать очередной пакет.

const int rc = recv(sock_, buffer.data() + BUFFER_WRITE_OFFSET,

BUFFER_SIZE_IP, 0);

if (-1 == rc)

{

// Ошибка приема данных — перестать читать пакеты.

std::cerr

<< "recv() failed: " << sock_wrap_.get_last_error_string()

<< std::endl;

return false;

}

// Соединение разорвано — перестать читать пакеты.

if (!rc) return false;

std::cout << rc << " bytes received..." << std::endl;

В методе формируется буфер, и по смещению в него записывается принятый из сети пакет. Далее требуется правильно заполнить поля заголовка, такие как время и длина, и записать пакет с заголовком в файл:

using namespace std::chrono;

// Рассчитать временную метку пакета.

const auto cur_time = duration_cast<microseconds>(

time_point_cast<microseconds>(

system_clock::now()).time_since_epoch()

);

const auto t_s = seconds(duration_cast<seconds>(cur_time));

const auto u_s = cur_time - duration_cast<microseconds>(t_s);

// Установить поля заголовка PCAP.

pkt->ts.tv_sec = t_s.count();

pkt->ts.tv_usec = u_s.count();

pkt->caplen = rc + BUFFER_ADD_HEADER_SIZE;

pkt->len = rc + BUFFER_ADD_HEADER_SIZE;

// Запись пакета в файл.

of_.write(buffer.data(), rc + BUFFER_SIZE_HDR + BUFFER_ADD_HEADER_SIZE);

of_.flush();

return true;

}

Остается только переключить адаптер в неразборчивый режим и вызвать метод захвата пакетов в цикле:

bool Sniffer::start_capture()

{

if (started_ || !initialized_) return false;

started_ = true;

// Переключить адаптер в неразборчивый режим.

if (!switch_promisc(true)) return false;

std::cout << "Starting capture on interface " << if_name_ << std::endl;

// Начать цикл захвата.

while (started_)

{

if (!capture()) return false;

}

return true;

}

Попробуем запустить сниффер на адаптере, через который идет трафик:

➭ sudo build/bin/b01-ch21-raw-sniffer wlo1 122.pcap

Device index = 3

Starting capture on interface wlo1

300 bytes received...

300 bytes received...

300 bytes received…

...

Видно, что были приняты данные. Теперь откроем этот файл в Wireshark:

➭ wireshark 122.pcap

На рис. 22.2 в окне дампа Wireshark видно, что в файл были записаны кадры с HTTP-запросами и ответами.

Это нормально, потому что в момент запуска сниффера работал браузер и обменивался данными по HTTP с веб-сервером.

Рис. 22.2. Окно данных пакета в Wireshark

Сниффер на Python

Мы не будем разбирать готовое решение, как делали ранее, а попробуем реализовать свой простой сниффер, по аналогии с предыдущим.

В сниффере, реализованном на C++, мы не занимались обработкой пакетов, а сохраняли их в известный формат, который мог быть открыт, например Wireshark. Здесь же добавим простейшую обработку, которая производится классами-обработчиками, реализованными условным «пользователем». Сниффер будет выполнять печать различных полей заголовков в консоль.

Обработчики будут иметь константные и вычисляемые поля, часть из которых должен определить пользователь:

• Код протокола — это некий код, по которому идентифицируется протокол в PDU уровня ниже. Этот код присутствует только для протоколов выше канального уровня, то есть, например, у Ethernet такого кода нет.

• Строка формата — строка парсера стандартного модуля struct, который используется для разбора заголовка пакета.

• Код вышележащего протокола — вычисляемое свойство, говорящее о том, какой PDU несет в полезной нагрузке данный протокол. Например, 6 для TCP и 17 для UDP.

• Смещение полезной нагрузки — место в буфере, содержащее пакет, с которого начинаются пользовательские данные.

Для произвольного пользовательского протокола код может быть, например, таким:

class ExampleProtocolHeader(NetworkProtocolHeader):

# Уникальный идентификатор протокола.

# Другого протокола с таким кодом быть не может.

protocol_code = 0xdead

# Распаковка осуществляется из сетевого формата, о чем говорит знак !.

format_string = '!BH16s'

def __init__(self, packet):

# Вызов конструктора предка.

super().__init__(packet)

# Все поля задаются здесь.

self.field_1, self.field_2, self.field_n = self.fields

@property

def inner_protocol_code(self) -> int:

"""

Код для вышележащего протокола, если таковой существует.

"""

return self.field_1

@property

def payload_offset(self) -> int:

"""

Смещение, по которому находятся полезные данные.

Возможно, PDU другого протокола.

"""

return self.field_3

Для протоколов транспортного уровня и выше код берется из списка IANA Assigned Internet Protocol Numbers179. Это зарегистрированные IANA-номера, которые в Unix-подобных системах можно обнаружить внутри ранее упомянутого файла /etc/protocols.

На канальном уровне все зависит от конкретного протокола. Например, для Ethernet поле EtherType стандартизовано IEEE180. У IANA также имеется список кодов для этого поля, но коды в нем отличаются от кодов из предыдущего списка. Например, IPv4 в первом списке имеет код 4, то есть это IP-пакет, инкапсулированный в один из других вышележащих протоколов. А в списке EtherType код для IPv4 будет равен 0x0800; это означает, что данный Ethernet-кадр переносит IP-пакет.

В общем случае один класс-обработчик может быть привязан к нескольким номерам и требуется учитывать источник, откуда пришли данные. А с другой стороны, кадры Ethernet, например, тоже могут быть инкапсулированы в IP-датаграмму. Но в сниффере мы несколько упростим задачу и не будем учитывать данный нюанс.

Парсер в данном сниффере представляет собой простой конвейер, в котором обработчик следующего заголовка в стеке выделяется на основе кода вышележащего протокола. Этот протокол возвращается предыдущим обработчиком, как и смещение, по которому находится заголовок.

Получение строкового представления данных реализовано прямо в классах-обработчиках.

Внимание! В реальном коде следует выделять представление данных в отдельные классы или другие отдельные сущности.

Сначала определим константы, а также импортируем уже знакомый нам класс, переключающий режим интерфейса на широковещательный:

import struct

import binascii

import socket

from typing import Any, final, Final

import sys

from abc import abstractmethod

# Уже знакомый нам класс.

from promisc_switcher.promisc_switcher import InterfacePromiscSwitcher

# Константы из /usr/include/linux/if_ether.h:

ETH_P_IP = 0x0800

ETH_P_ALL = 0x0003

# Из /usr/include/asm-generic/socket.h:

SO_BINDTODEVICE = 25

# Зададим большой размер буфера для приема данных.

RECV_BUF_SIZE = 65535

Теперь разберемся с обработчиками. Выше было видно, что обработчик протокола наследуется от базового класса, общего для всех обработчиков, — класса NetworkProtocolHeader, в котором реализованы основные функции:

• Разбор заголовка в свойстве fields, где просто вызывается функция unpack() из модуля struct.

• Метод unpack_packet() для распаковки пакета, который вызывает обработчики из цикла, выбирая их по коду протокола.

• Абстрактные «свойства», которые пользователь должен переопределить в наследниках.

Использование такого класса упрощает работу программистам, реализующим обработчики. Они просто наследуют данный класс и переопределяют свойства. Этого достаточно: обработчики автоматически регистрируются в базовом классе.

Ранее функциональность регистрации была реализована через метаклассы. Но как показал Никита Соболев, использование современного Python значительно упрощает реализацию механизма регистрации. Сейчас он реализован через специальный метод __init_subclass__(), который вызывается, когда от класса наследуется другой класс.

При наследовании класс обработчика регистрируется в методе __init_subclass__():

class NetworkProtocolHeader:

channel_protocols: Final[set[type]] = set()

protocols: Final[dict[int, type]] = {}

format_string = ''

@final

def __init_subclass__(cls):

# Вычисление размера заголовка из поля строки формата, задающего

# порядок его разбора.

cls.header_size = struct.calcsize(cls.format_string)

try:

proto_code = cls.protocol_code

except AttributeError:

# Это протокол канального уровня, так как у него

# отсутствует код.

NetworkProtocolHeader.channel_protocols.add(cls)

else:

proto_class = NetworkProtocolHeader.protocols.get(proto_code)

if proto_class is not None:

# В production-ready-коде необходимо реализовать

# свои классы исключений.

raise KeyError(

f'Class for the protocol code {proto_code} already '

f'exists [{proto_class.__name__}]!'

)

# Добавить класс обработчика протокола уровня выше канального

# в словарь.

NetworkProtocolHeader.protocols[proto_code] = cls

def __init__(self, packet):

self._packet = packet

Протокол канального уровня, то есть самый нижний, не имеет кода. Попытка его получения вызывает исключение, что говорит о необходимости добавления протокола в список канальных.

Остальные протоколы, у которых есть поле protocol_code, добавляются в словарь, но только при условии, что в нем еще нет протокола с таким же кодом.

Каждый протокол, кроме транспортных, которые в цепочке являются конечными, сам реализует свойство для получения кода вышележащего протокола и смещения данных.

В свойстве fields бинарные данные заголовка преобразуются в структуру по строке формата:

@final

@property

def fields(self):

# Получить поля заголовка.

return struct.unpack(self.format_string,

self._packet[:self.header_size])

# "Абстрактное свойство".

@property

@abstractmethod

def inner_protocol_code(self) -> int:

"""Переопределяемый код вышележащего протокола."""

pass

@property

@abstractmethod

def payload_offset(self) -> int:

"""Переопределяемое смещение заголовка вышележащего протокола."""

pass

Метод класса unpack_packet() здесь является наиболее сложной частью. Сначала он выполняет некоторые проверки:

@final

@classmethod

def unpack_packet(cls, base_protocol, packet, *,

skip_channel_proto_check: bool = False) -> list[Any]:

"""Метод "распаковки" пакета."""

if not issubclass(base_protocol, NetworkProtocolHeader):

raise TypeError(

f'{base_protocol!r} is not a descendant class of the '

f'{cls.__class__.__name__}!',

)

# Отключение проверки того факта, что начальный протокол является

# протоколом канального уровня. Для чего это нужно, рассказано ниже.

if (

not skip_channel_proto_check

and base_protocol not in cls.channel_protocols

):

# Необходимо генерировать исключение с классом,

# определенным пользователем.

raise KeyError(

f'{base_protocol.__name__} is not a channel protocol class!')

# Результат — список, в который будут записаны все верно

# инициализированные обработчики протоколов в порядке иерархии.

headers = []

cur_header = base_protocol(packet)

headers.append(cur_header)

Затем сводится к циклу, где по коду протокола выбирается класс обработчика и создается его экземпляр, который и разбирает пакет, переданный в конструкторе:

while True:

# Смещение обрабатываемого заголовка, которое определяется

# предыдущим вызванным обработчиком.

packet = packet[cur_header.payload_offset:]

try:

# В цикле обработки получаем требуемый класс обработчика

# по коду протокола.

inner_code = cur_header.inner_protocol_code

except NotImplementedError:

# Протокола вышележащего уровня в данном PDU

# не предполагается.

break

else:

proto_header_class = cls.protocols.get(inner_code)

if proto_header_class is None:

break

# Если такой класс есть, создать его объект — непосредственно

# обработчик, которому передаются обрабатываемые данные.

cur_header = proto_header_class(packet)

# Этот объект добавляется к результату.

headers.append(cur_header)

return headers

Осталось реализовать классы, разбирающие заголовки протоколов. Начнем с Ethernet. На рис. 22.3 показан формат кадра.

Рис. 22.3. Формат Ethernet-кадра

Соответственно, реализация «обработки» кадра не сильно отличается от примера обработчика:

class EthernetHeader(NetworkProtocolHeader):

# "!" — распаковка из сетевого порядка байтов.

format_string = '!6s6sH'

def __init__(self, packet):

super().__init__(packet)

# Поля.

self.destination_mac_address, self.source_mac_address, \

self.protocol = self.fields

@property

def inner_protocol_code(self) -> int:

return int(self.protocol)

@property

def payload_offset(self) -> int:

return self.header_size

@staticmethod

def print_mac(addr):

pa = binascii.hexlify(addr).decode()

# Склеиваем MAC: по две шестнадцатеричные цифры с двоеточием

# между ними.

return ':'.join([pa[i:i + 2] for i in range(0, len(pa), 2)])

def __str__(self):

# В продуктовом коде метод для представления в строковой форме

# лучше упростить, а для печати заголовка реализовать отдельный

# вариант или варианты представления.

return f'[{self.__class__.__name__}]:\n' \

f'  Src MAC: {self.print_mac(self.destination_mac_address)}\n'\

f'  Dst MAC: {self.print_mac(self.source_mac_address)}\n' \

f'  EtherType: 0x{self.protocol:04x}'

Формат заголовков IP несколько сложнее. Заголовок IPv4 показан на рис. 22.4.

Рис. 22.4. Формат заголовка IPv4

Видим, что в заголовке есть четырехбайтные и невыровненные поля, из-за чего требуются манипуляции с битами. Поэтому в конструкторе IPv4-заголовка выполняется некоторая пользовательская обработка:

class Ipv4Header(NetworkProtocolHeader):

# Это значение взято из списка значений EtherType.

# Для корректной обработки необходим список кодов.

# Но в учебном примере мы эту поддержку не реализуем.

protocol_code = 0x0008

format_string = '!BBHHHBBH4s4s'

# Длина слова в байтах.

word_length = 4

def __init__(self, packet):

super().__init__(packet)

(

self.ip_version,

self.tos,

self.total_length,

self.identification,

self.fragment_offset,

self.ttl,

self.protocol,

self.header_checksum,

self.source_address,

self.destination_address,

) = self.fields

# С учетом переменного числа опций IP-заголовок может иметь разную

# длину.

# 4-битное поле Internet Header Length содержит размер заголовка

# в 32-битных словах.

self.ihl = self.ip_version & 0x0f

self.ip_version &= 0xf0

self.ip_version >>= 4

assert self.ip_version == 4

# Минимальная длина — 20 байт. Максимальная — 60, но здесь

# эта проверка не реализована.

assert self.ihl * self.word_length >= self.header_size

Класс также содержит два свойства, позволяющих получить код протокола и смещение данных в PDU:

@property

def inner_protocol_code(self) -> int:

return self.protocol

@property

def payload_offset(self) -> int:

# Данные следуют сразу после заголовка.

return self.ihl * self.word_length

def __str__(self):

spacing = ' ' * 6

return (

f'    [{self.__class__.__name__}]:\n'

f'{spacing}Payload Off: {self.payload_offset}\n'

f'{spacing}TOS: {self.tos}\n'

f'{spacing}TTL: {self.ttl}\n'

f'{spacing}Proto: {self.protocol}\n'

f'{spacing}Length: {self.total_length}\n'

f'{spacing}Src IP: '

f'{socket.inet_ntop(socket.AF_INET, self.source_address)}\n'

f'{spacing}Dst IP: '

f'{socket.inet_ntop(socket.AF_INET, self.destination_address)}'

)

Метод для генерации строкового представления выводит поля заголовка IP.

Возможна упомянутая выше ситуация, когда в IP-дейтаграмме содержится код протокола 4. Это нормально и означает, что используется протокол IP in IP181, то есть один IP-пакет инкапсулируется в другой, как показано на рис. 22.5.

Рис. 22.5. Инкапсуляция в IP

Но обработка таких протоколов в примере сниффера не поддерживается.

Разберем заголовок IPv6, который показан на рис. 22.6.

Рис. 22.6. Формат IP-заголовка для IPv6

В IPv6 необходим разбор опций. Последняя опция будет содержать идентификатор вышележащего протокола в поле Next Header, но мы несколько упростим обработчик:

class Ipv6Header(NetworkProtocolHeader):

# Это значение взято из списка значений EtherType.

protocol_code = 0x86DD

format_string = '!BBHHBB16s16s'

word_length = 4

def __init__(self, packet):

super().__init__(packet)

(

first_byte,

second_byte,

flow_label_lsb,

self.payload_length,

self.next_header,

self.hop_limit,

self.source_address,

self.destination_address,

) = self.fields

self.ip_version = (first_byte & 0xF0) >> 4

self.traffic_class = (

(first_byte & 0x0F) << 4) |

((second_byte & 0xF0) >> 4)

)

self.flow_label = (second_byte & 0x0F << 16) | flow_label_lsb

assert self.ip_version == 6

Остальные свойства и метод __str__() аналогичны таковым для IPv4:

@property

def inner_protocol_code(self) -> int:

# На самом деле необходимо разбирать опции заголовка и возвращать

# поле Next Header последней опции.

return self.next_header

@property

def payload_offset(self) -> int:

return self.header_size

def __str__(self):

spacing = ' ' * 6

printable_addr = socket.inet_ntop(

socket.AF_INET6,

self.destination_address

)

return (

f'    [{self.__class__.__name__}]:\n'

f'{spacing}Traffic Class: '

f'{self.traffic_class}\n'

f'{spacing}Flow Label: {self.flow_label}\n'

f'{spacing}Next Header: {self.next_header}\n'

f'{spacing}Payload Length: '

f'{elf.payload_length}\n'

f'{spacing}Src IP: '

f'{socket.inet_ntop(socket.AF_INET6, self.source_address)}\n'

f'{spacing}Dst IP: '

f'{printable_addr}'

)

По аналогии реализуем обработчик ICMP. Так как ICMP не содержит вложенных протоколов, его свойство inner_protocol_code генерирует исключение:

class IcmpHeader(NetworkProtocolHeader):

# Данный код и коды у TCP и UDP взяты из списка IANA.

protocol_code = 1

format_string = '!BBH'

def __init__(self, packet):

super().__init__(packet)

self.message_type, self.code, self.checksum = self.fields

@property

def inner_protocol_code(self) -> int:

raise NotImplementedError('ICMP has not inner protocol!')

@property

def payload_offset(self) -> int:

# Для ICMP размер заголовка всегда постоянный.

return self.header_size

def __str__(self):

spacing = ' ' * 10

return (

f'        [{self.__class__.__name__}]:\n'

f'{spacing}Message Type: {self.message_type}\n'

f'{spacing}Code: {self.code}\n'

f'{spacing}Checksum: 0x{self.checksum:04x}'

)

class Icmp6Header(IcmpHeader):

protocol_code = 58

@property

def inner_protocol_code(self) -> int:

raise NotImplementedError('ICMP6 has not inner protocol!')

Осталось создать обработчики транспортных протоколов. Заголовок TCP, показанный на рис. 22.7, вероятно, наиболее сложный:

Рис. 22.7. Формат TCP-заголовка

Но его обработчик несложен:

class TcpHeader(NetworkProtocolHeader):

# Номер из списка IANA.

protocol_code = 6

format_string = '!HHLLBBHHH'

word_length = 4

def __init__(self, packet):

super().__init__(packet)

(

self.source_port, self.destination_port,

self.sequence_number, self.acknowledge_number, offset_reserved,

self.tcp_flag,

self.window,

self.checksum,

self.urgent_pointer

) = self.fields

self.data_offset = ((offset_reserved & 0xf0) >> 4) * self.word_length

@property

def inner_protocol_code(self) -> int:

# Поверх TCP работают произвольные пользовательские протоколы.

raise NotImplementedError(

'TCP has arbitrary user-defined inner protocol!')

@property

def payload_offset(self) -> int:

return self.data_offset

def __str__(self):

spacing = ' ' * 10

return (

f'        [{self.__class__.__name__}]:\n'

f'{spacing}Src Port: {self.source_port}\n'

f'{spacing}Dst Port: {self.destination_port}\n'

f'{spacing}Seq Number: {self.sequence_number}\n'

f'{spacing}Ack Number: {self.acknowledge_number}\n'

f'{spacing}Data Off: {self.data_offset}\n'

f'{spacing}Flags: 0x{self.tcp_flag:04x}\n'

f'{spacing}Window: {self.window}\n'

f'{spacing}Urgent Ptr: {self.urgent_pointer}\n'

f'{spacing}Checksum: 0x{self.checksum:04x}'

)

Свойство inner_protocol_code также генерирует исключение, но по той причине, что TCP инкапсулирует произвольные данные и не знает, что за блок в конкретном сегменте. А приложения должны сами знать вышележащий протокол, который они используют.

Вероятно, один из самых простых обработчиков для UDP:

class UdpHeader(NetworkProtocolHeader):

# Номер из списка IANA.

protocol_code = 17

format_string = '!HHHH'

def __init__(self, packet):

super().__init__(packet)

(

self.source_port, self.destination_port,

self.length, self.checksum

) = self.fields

@property

def inner_protocol_code(self) -> int:

# Пользователь далее реализует собственные протоколы.

raise NotImplementedError(

'UDP has arbitrary user-defined inner protocol!')

@property

def payload_offset(self) -> int:

return self.header_size

def __str__(self):

spacing = ' ' * 10

return (

f'        [{self.__class__.__name__}]:\n'

f'{spacing}Src Port: {self.source_port}\n'

f'{spacing}Dst Port: {self.destination_port}\n'

f'{spacing}Length: {self.length}\n'

f'{spacing}Checksum: 0x{self.checksum:04x}'

)

Теперь реализуем запуск сниффера. Он должен перевести интерфейс в неразборчивый режим и начать захват пакетов в цикле:

if len(sys.argv) != 2:

print(f'{sys.argv[0]} <interface name>')

sys.exit(1)

interface_name = sys.argv[1]

# Используем уже реализованный переключатель в promisc-режиме.

with InterfacePromiscSwitcher(interface_name) as ips:

if 'win32' == sys.platform:

# В ОС Windows можно использовать обычный raw-сокет.

sock = socket.socket(socket.AF_INET, socket.SOCK_RAW,

socket.IPPROTO_IP)

sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)

# Вспоминаем, что привязка к интерфейсу через данную опцию

# работает для сокетов домена AF_INET.

sock.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE,

interface_name.encode())

sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)

else:

# В Linux используются сокеты домена AF_PACKET, чтобы захватить

# также заголовки Ethernet-кадров.

sock = socket.socket(socket.PF_PACKET, socket.SOCK_RAW,

socket.ntohs(ETH_P_ALL))

# Привязка через SO_BINDTODEVICE для сокетов AF_PACKET не работает.

# Можно использовать такой вариант:

# socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP)

# Тогда не будет возможности получать заголовок кадра Ethernet.

# Привязка к интерфейсу через bind() работает везде.

# Сюда передается обычная строка — имя сетевого интерфейса.

# Получение индекса и передача структуры sockaddr_ll не требуется.

sock.bind((interface_name, 0))

Интерфейс корректно настроен, запустим цикл обработки пакетов:

# Цикл захвата пакетов.

while True:

data = sock.recvfrom(RECV_BUF_SIZE)

# В случае использования raw-сокетов декодирование начинается с IP.

# Проверка на то, что первый декодируемый протокол канального уровня

# выключается:

# print(NetworkProtocolHeader.unpack_packet(Ipv4Header, data[0],

#                                           True))

for p in NetworkProtocolHeader.unpack_packet(EthernetHeader, data[0]):

# У всех обработчиков вызывается метод __str__().

print(p)

print()

Все, сниффер готов. Запустим его с правами суперпользователя и посмотрим на результат:

➭ sudo src/book01/ch22/python/python-sniffer.py wlo1

[EthernetHeader]:

Src MAC: 08:71:90:0f:45:10

Dst MAC: 4c:5e:0c:a7:2e:f6

EtherType: 0x0800

[Ipv4Header]:

Payload Off: 20

TOS: 0

TTL: 57

Proto: 6

Length: 286

Src IP: 91.105.192.100

Dst IP: 192.168.2.13

[TcpHeader]:

Src Port: 80

Dst Port: 58200

Seq Number: 1418259835

Ack Number: 638575873

Data Off: 32

Flags: 0x0018

Window: 32768

Urgent Ptr: 0

Checksum: 0x933b

[EthernetHeader]:

Src MAC: 4c:5e:0c:a7:2e:f6

Dst MAC: 08:71:90:0f:45:10

EtherType: 0x0800

[Ipv4Header]:

Payload Off: 20

TOS: 0

TTL: 64

Proto: 6

Length: 585

Src IP: 192.168.2.13

Dst IP: 91.105.192.100

[TcpHeader]:

Src Port: 58200

Dst Port: 80

Seq Number: 638575873

Ack Number: 1418260069

Data Off: 32

Flags: 0x0018

Window: 9698

Urgent Ptr: 0

Checksum: 0xe0be

...

Видим, что Ethernet-кадры разбираются, вполне корректно выводится IP-заголовок и производится определение вышележащего протокола: TCP, UDP, а если запустить ping, то и ICMP.

Внимание! Код сниффера не содержит преобразований байтового порядка, таких как socket.ntohs(). Это работает потому, что формат распаковки задан со знаком «!» в начале, что говорит о необходимости читать все числа так, будто их байты записаны в сетевом порядке.

Поэкспериментировать с заменой сокетов AF_PACKET на raw-сокеты вы можете самостоятельно, так же как и расширить сниффер для обработки других протоколов и, например, сохранения данных.

Такой сниффер, как Wireshark, может работать с правами обычного пользователя. Ему не требуются root-права.

Достигается это несколькими способами:

• в случае Windows — работающей в фоне службой;

• для старых Unix-подобных систем — вынесением кода перехвата пакетов в отдельный бинарный файл и установкой ему suid-бита, что небезопасно;

• для Linux и других новых систем — через привилегии.

В Linux бинарный файл /usr/bin/wireshark предоставляет интерфейс пользователя, не осуществляя захват. В действительности захватывает пакеты утилита /usr/bin/dumpcap.

Файл этой утилиты имеет установленные привилегии CAP_NET_ADMIN и CAP_NET_RAW+eip, которые позволяют настраивать интерфейсы для перевода их в неразборчивый режим и создавать сырые сокеты и сокеты AF_PACKET.

Посмотреть эти привилегии можно через утилиту getcap.

Владеет утилитой /usr/bin/dumpcap группа wireshark, и кроме пользователя root, ее запуск разрешен только пользователям из этой группы.

Таким образом:

• реализуется захват от любого пользователя без использования suid;

• чтобы любой пользователь мог выполнять захват через Wireshark, ему достаточно добавиться в группу wireshark. Например, так: usermod -a -G wireshark <username>.

Решения для захвата трафика

Большинство снифферов не работают с интерфейсами напрямую, сами не записывают пакеты и не реализуют поддержку формата PCAP. На разных платформах эти действия требуют разных подходов и решений, часть из которых рассмотрена ниже.

Сами же снифферы предоставляют интерфейс, средства анализа и прочие дополнительные функции.

Примеры снифферов

Существует множество разнообразных снифферов для выполнения разных задач:

• Wireshark182 — самый известный сниффер и один из наиболее функциональных. Кросс-платформенный, с открытым исходным кодом, он поддерживает множество протоколов и имеет встроенный скриптовый движок.

• Gigamon Deep Observatory Pipeline183 — платный и проприетарный инструмент. Сложный конвейер, состоящий из множества компонентов. Сборщики трафика работают везде: от железа до контейнеров и виртуальных машин. Брокер распределяет данные, которые затем проходят фильтрацию, дедупликацию и прочую обработку. Затем по протоколу Netflow данные отправляются в Application Metadata Intelligence, где обогащаются различными метаданными. Например, там выделяются протоколы. Обработанные данные поступают на менеджерскую консоль и предоставляются человеку в удобочитаемом виде.

• Commview184 — платный проприетарный сниффер с широкими возможностями. Выпускается давно и поддерживает более 100 протоколов. Может расшифровывать SSL/TLS «на лету», а также захватывать трафик с удаленных машин, если на них установлен агент.

• EtherApe185 — графический монитор сети: позволяет строить ее карту, прослушивая трафик. Содержит анализатор пакетов.

• ngrep186 — Network Grep, который работает так же, как и grep, но с пакетами из сети. Понимает TCP, UDP, ICMP, IGMP, PPP и др. Поддерживает фильтрацию по разным признакам трафика. Например, следующая команда выполнит отбор TCP-сегментов на порту 80, данные в которых начинаются с GET или POST, иными словами — HTTP-запросов: ngrep -l -q -d eth0 -i "^GET |^POST " tcp and port 80.

• tcpdump187 — сниффер, широко распространенный в Unix-подобных системах. Реализован поверх библиотеки Pcap, поэтому является кросс-платформенным и работает как на Unix-подобных системах, так и в Windows188. Может использовать для записи формат PCap, понятный многим приложениям для анализа сети.

• windump189 — свободная реализация перехватчика трафика для Windows.

• pktmon190 — анализатор трафика от Microsoft для новых версий Windows.

• snoop191 — утилита, которая в Solaris заменяет ngrep и tcpdump.

• JustSniffer192 — простой сниффер. Захватывает пакеты и выводит их данные в настраиваемый текстовый протокол.

• netsniff-ng toolkit193 — набор инструментов для анализа и генерации194 пакетов в Linux. Построен на основе RX_RING/TX_RING, которые являются специфичными для Linux способами kernel bypass, похожими на PF_RING. Об этом мы еще упомянем в следующей главе.

Подробнее особенности разных средств анализа трафика расписаны в таблице195 на Wikipedia.

Wireshark

Wireshark — бесплатный кросс-платформенный анализатор пакетов с открытым исходным кодом, широко используемый для анализа трафика и диагностики. Он имеет графический интерфейс, консольный интерфейс, а также интерфейс на основе NCurses. Распространяется по лицензии GPL.

Используя Wireshark, пользователь может перевести интерфейсы Ethernet в неразборчивый режим, а Wi-Fi — в режим мониторинга. Это дает возможность захватывать пакеты на удаленных машинах, передавая их на узел с установленным Wireshark для анализа в режиме реального времени.

Wireshark способен расшифровывать SSL/TLS «на лету» и выполнять сложный анализ и разбор данных. Для разбора могут быть использованы встроенные сценарии на языке Lua.

Wireshark на платформах Linux, BSD и MacOS X работают поверх libpcap. В дальнейшем мы будем часто использовать Wireshark.

Network Monitor

В Windows, помимо сторонних решений на основе Pcap, существует инструмент Network Monitor196. Его устройство показано на рис. 22.8. Он уже не поддерживается, но до сих пор широко используется и его можно скачать197.

Кроме того, существует SDK198, который позволяет использовать возможности Network Monitor программно и писать для него модули.

Рис. 22.8. Схема Network Monitor

Network Monitor включает драйвер NMNT.sys, получающий данные с NDIS-драйвера и распределяющий их пакетным провайдерам, которые работают на уровне пользователя. Монитор может на уровне драйвера устанавливать фильтры захвата199 по аналогии с libpcap.

Пакетные провайдеры реализованы как DLL, представляющие набор COM-интерфейсов.

Некоторые основные провайдеры:

• IRTC200 — для захвата данных в реальном времени.

• IDelaydC201 — для захвата трафика в файл.

• IESP202 — для захвата статистик-трафика и записи в файл формата ESP.

Интерфейсы содержат следующие методы:

• Connect() и Disconnect() для управления подключением к сети.

• Start(), Stop(), Pause(), Resume() для начала и окончания захвата.

• Configure() для настройки захвата — через него передаются управляющие BLOB'ы203.

• Несколько методов для получения состояния и статистики.

На рис. 22.9 показано, как пакетные провайдеры работают с адаптерами.

Можно реализовать свои пакетные провайдеры, которые работают в обход NDIS, обращаясь к драйверу адаптера напрямую. Это делает пакетный монитор очень гибким решением, способным принимать данные в обход сетевой подсистемы ОС Windows.

Рис. 22.9. Взаимодействие пакетных провайдеров и адаптеров

Провайдеры отдают данные мониторам204, управляемым службой Monitor Control Service. Служба загружает DLL-библиотеки, в которых реализованы мониторы, конфигурирует, создает и уничтожает экземпляры мониторов.

Мониторы отвечают за обработку трафика и генерацию событий по условиям. Например, монитор будет в реальном времени получать трафик от провайдера IRTC и после выполнения заданного условия, например поступления IP-пакета с некорректным адресом, сгенерирует некоторое событие. Эти события можно увидеть в интерфейсе утилиты управления мониторами — Monitor Control Tool.

Также мониторами можно управлять, используя COM-интерфейс IMonitor205. Схема взаимодействия компонентов изображена на рис. 22.10. Особенность мониторов в том, что они быстро оценивают трафик.

Вторая часть работы инструмента Network Monitor — отложенный анализ данных, сохраненных в файлы через провайдер IDelaydC.

Рис. 22.10. Взаимодействие компонентов сетевого монитора

Чтобы пользователь мог работать с этим трафиком, система предоставляет Network Monitor UI. Он использует nmapi.dll и задействует эксперты206 и парсеры207 для анализа. Эти компоненты тоже реализованы как DLL.

Парсер обнаруживает и разбирает протоколы. Он работает, когда его вызывает сетевой монитор или эксперт. Каждый парсер разбирает один протокол. Как правило, в одной библиотеке реализован один парсер, однако DLL может содержать несколько парсеров.

Данные берутся из файлов отложенного захвата и передаются парсеру покадрово. Парсер определяет протокол и по содержимому кадра устанавливает свойства экземпляра его элементов: пакетов, дейтаграмм и т.п.

Эксперт анализирует сетевой трафик, собранный NPP и помещенный в файл захвата. Для этого он работает с анализатором протокола.

Из кадра в файле захвата с помощью парсеров, к примеру, можно распознать такие протоколы, как SMB или TCP/IP. Когда протокол кадра идентифицирован, эксперт извлекает данные кадра. Эксперты могут использоваться для получения данных о корреляции, статистик по конкретным протоколам и других подобных задач.

Во время установки сетевого монитора в подкаталог Experts устанавливаются следующие библиотеки экспертов:

• Coalesce.dll — инструмент объединения протоколов.

• Propdist.dll — эксперт по распределению свойств.

• Protdist.dll — эксперт по распределению протоколов.

• Resptime.dll — эксперт по среднему времени ответа сервера.

• Tcpipe.dll — эксперт по повторной передаче TCP.

• Topuser.dll — эксперт по наиболее частым пользователям.

Пользователь также может писать библиотеки экспертов самостоятельно.

Интерфейс монитора схож с интерфейсом Wireshark и показан на рис. 22.11.

Рис. 22.11. Интерфейс монитора Netmon

Монитор — интересное решение, но начиная с Windows 10, он заменяется на более ограниченный по возможностям сниффер pktmon208, который похож на tcpdump, но может читать файлы ETL, генерируемые монитором, и PCAPNG — расширенный формат PCAP от Wireshark.

Формат PCAP и поддерживающие его библиотеки

Мы уже рассмотрели, что собой представляет формат PCAP, когда реализовывали сниффер на C++. Но PCAP209 — это прежде всего API, который служит для захвата трафика.

Его реализацию предоставляют несколько библиотек:

• Libpcap210 — C-библиотека под Unix-подобные системы. Это разработка Lawrence Berkeley National Laboratory Network Research Group211. Сейчас ее развивает The Tcpdump Group. Код открыт по лицензии BSD. Эту библиотеку можно считать эталонной реализацией.

• Winpcap212 — порт libpcap на Windows. Реализован на основе NDIS 5.x213 и, похоже, больше не поддерживается.

• Win10Pcap214 — библиотека, основанная на Winpcap, но поддерживающая модель драйверов NDIS 6.x215. Бинарно совместима с winpcap. Исходный код открыт, распространяется по лицензии GPLv2. Совместима со всеми новыми версиями ОС Windows.

• Npcap216 — библиотека для захвата и отправки пакетов от проекта Nmap для Microsoft Windows. Поддерживает все архитектуры и релизы ОС Windows, захват с беспроводных адаптеров, некоторые особенности Windows. Последние версии основаны на NDIS 6.x. Лицензия коммерческая, но код открытый.

Кроме API, существует формат PCAP, который был использован в сниффере выше.

Файл в этом формате состоит из:

• заголовка, содержащего версии формата, временную зону и прочие служебные поля;

• массива пакетов, каждый из которых предваряется заголовком пакета.

Структуры заголовков были приведены ранее, подробно формат описан на сайте Wireshark217. Библиотеки поддерживают этот формат, позволяя читать PCAP-файлы и записывать в них данные.

На основе библиотек PCAP работают tcpdump, ngrep, Wireshark, EtherApe и многие другие снифферы, IDS, сетевые анализаторы. Биндинги одного из вариантов libpcap существуют для языков Python, Java, C# и многих других.

Новые версии библиотек частично поддерживают формат PcapNg218 — основной для Wireshark.

Общий принцип работы с этими библиотеками:

1. Получить интерфейс для захвата трафика.

2. Инициализировать PCAP, указав ему устройства, с которых будет производиться захват.

3. Скомпилировать и применить фильтры на трафик, если требуется.

4. Захватить трафик. Если необходимо, обрабатываем и анализируем пакеты.

5. Закрыть сессию захвата, когда работа окончена.

API достаточно прост219:

• pcap_init() — инициализировать библиотеку. Функция выполняет платформозависимые действия, например вызов WSAStartup() на ОС Windows.

• pcap_find_all_devs() — получить устройства.

• pcap_create() — создать сессию захвата.

• pcap_activate() — запустить сессию захвата.

• pcap_set_promisc() — установить неразборчивый режим.

• pcap_set_rfmon() — установить режим мониторинга Wi-Fi.

• pcap_dump_open() — открыть файл дампа .pcap.

• pcap_dump() — записать дамп в файл.

• pcap_dump_close() — закрыть файл дампа.

• Для чтения сессии из файла дампа используется функция pcap_open_offline(), возвращающая дескриптор, или pcap_fopen_offline(), возвращающая FILE *.

• Снапшотами управляет pcap_set_snaplen(), позволяя захватывать, например, только часть пакета с TCP-заголовками.

• Фильтры компилируются через pcap_compile(), а устанавливаются через pcap_setfilter().

• Непосредственно захват производится через функции pcap_loop() в цикле либо pcap_dispatch() и pcap_next().

Библиотека хорошо документирована.

Чтобы прочитать документацию, необходимо установить пакет с библиотекой. В Linux он называется libpcap соответствующей версии, pcap и libpcap-dev, в зависимости от дистрибутива.

В разделе 3 PCAP содержится как подробное руководство, так и отдельные страницы по функциям:

➭ man pcap

Формат строк фильтров описан в man 7 pcap-filter и вполне понятен тем, кто пользуется tcpdump.

Пример фильтра, выбирающего HTTP-пакеты IPv4, на порту 80:

tcp port 80 and (((ip[2:2] — ((ip[0]&0xf)<<2)) — ((tcp[12]&0xf0)>>2)) != 0)

Если сделать вывод на консоль, мы увидим только пакеты, содержащие данные, а не пакеты SYN, FIN, ACK:

➭ sudo ./run tcpdump 'tcp port 80 and (((ip[2:2] — ((ip[0]&0xf)<<2)) — ((tcp[12]&0xf0)>>2)) != 0)'

tcpdump: verbose output suppressed, use -v[v]... for full protocol decode

listening on wlo1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

21:02:22.246799 IP 91.105.192.100.http > working-machine.37632: Flags [P.], seq 836468353:836468587, ack 2980469537, win 12258, options [nop,nop,TS val 116702986 ecr 2631864078], length 234: HTTP: HTTP/1.1 200 OK

21:02:22.248368 IP working-machine.37632 > 91.105.192.100.http: Flags [P.], seq 1:518, ack 234, win 2010, options [nop,nop,TS val 2631889105 ecr 116702986], length 517: HTTP: POST /api HTTP/1.1

21:02:30.893396 IP working-machine.53112 > 5.79.79.209.http: Flags [P.], seq 1978165765:1978166130, ack 718071210, win 64240, length 365: HTTP: GET / HTTP/1.1

3 packets captured

13 packets received by filter

2 packets dropped by kernel

Пример реализации сниффера на основе libpcap

С использованием библиотеки PCAP писать сниффер гораздо проще, — весь код управления адаптерами и работу с PCAP-файлами библиотека уже предоставляет. Но структуры протоколов все же придется описать самостоятельно.

Начнем с описания интерфейса класса, который распечатывает содержимое пакетов:

#pragma once

#include <pcap.h>

class PacketPrinter

{

public:

// Разобрать пакет и вывести содержимое на экран.

void got_packet(u_char *args, const pcap_pkthdr *header,

const u_char *packet);

private:

// Печать данных по 16 байт: смещение   hex   ascii.

// 00000   47 45 54 20 2f 20 48 54  54 50 2f 31 2e 31 0d 0a

//   GET / HTTP/1.1..

void print_hex_ascii_line(const u_char *payload, int len, int offset);

// Вывести содержимое данных пакета: не печатать бинарные данные.

void print_payload(const u_char *payload, int len);

private:

// Счетчик количества обработанных пакетов.

int packet_count_ = 1;

};

Код его методов реализуем позже, вначале опишем константы и заголовок Ethernet:

#include "packet_printer.h"

...

// Размер заголовка Ethernet — 14.

constexpr auto SIZE_ETHERNET = 14;

// MAC-адрес 6 байт.

constexpr auto ETHER_ADDR_LEN = 6;

// Размер IP-заголовка в байтах.

constexpr auto ip_header_size = 20;

// Заголовок Ethernet.

struct sniff_ethernet

{

// MAC-адрес назначения.

u_char ether_dhost[ETHER_ADDR_LEN];

// MAC-адрес источника.

u_char ether_shost[ETHER_ADDR_LEN];

// Код протокола сетевого уровня.

u_short ether_type;

};

Теперь опишем константы для IPv4 и его заголовок:

// Флаг Reserved, который всегда должен быть равен 0.

constexpr u_short ip_rf = 0x8000;

// Флаг Don't Fragment.

constexpr u_short ip_df = 0x4000;

// Флаг More Fragments.

constexpr u_short ip_mf = 0x2000;

// Маска для битов фрагментации IP.

constexpr u_short ip_offmask = 0x1fff;

// Заголовок IP.

struct sniff_ip

{

// Version << 4 | длина заголовка >> 2.

u_char ip_vhl;

// Тип сервиса, ToS.

u_char ip_tos;

// Полная длина.

u_short ip_len;

// Идентификатор.

u_short ip_id;

// Поле смещения фрагмента.

u_short ip_off;

// TTL.

u_char ip_ttl;

// Протокол.

u_char ip_p;

// Контрольная сумма.

u_short ip_sum;

// Адрес источника.

in_addr ip_src;

// Адрес назначения.

in_addr ip_dst;

};

Несколько функций получают длину заголовка и версию из байтового поля, содержащего их комбинацию:

// Длина заголовка IPv4.

constexpr u_char ip_hl(const sniff_ip *ip)

{

return (ip->ip_vhl) & 0x0f;

}

// Версия IPv4 — старшие 4 бита.

constexpr u_char ip_v(const sniff_ip *ip)

{

return (ip->ip_vhl) >> 4;

}

Заголовок для IPv6:

struct sniff_ipv6

{

// Поля Version и Class.

u_int version_class_flow;

// Длина полезной нагрузки.

u_short payload_len;

// Следующий заголовок.

u_char next_header;

// Ограничение на количество ретрансляций.

u_char hop_limit;

// Адрес источника.

struct in6_addr src;

// Адрес назначения.

struct in6_addr dest;

};

Опишем флаги TCP:

// Флаги TCP.

enum class tcp_flags : u_char

{

th_fin = 0x01,

th_syn = 0x02,

th_rst = 0x04,

th_push = 0x08,

th_ack_f = 0x10,

th_urg = 0x20,

th_ece = 0x40,

th_cwr = 0x80

};

Их мы используем при описании TCP-заголовка:

// Заголовок TCP.

struct sniff_tcp

{

typedef u_int tcp_seq;

// Порт источника.

u_short th_sport;

// Порт назначения.

u_short th_dport;

// Номер TCP-последовательности.

tcp_seq th_seq;

// Номер подтверждения.

tcp_seq th_ack;

// Смещение данных и поле reserved.

u_char th_offx2;

// Поле флагов.

tcp_flags th_flags;

// Размер окна.

u_short th_win;

// Контрольная сумма.

u_short th_sum;

// Указатель на срочные данные.

u_short th_urp;

};

// Получить смещение данных, убрав reserved.

constexpr u_char th_off(const sniff_tcp *th)

{

return (th->th_offx2 & 0xf0) >> 4;

}

В данном случае мы описываем структуры явно. Это кросс-платформенный вариант. Но в каждой операционной системе, которая поддерживает сеть, обычно есть заголовочные файлы с описанием подобных структур.

В различных ОС структуры описаны в разных файлах:

• В ОС Windows — в заголовочном файле netiodef.h. Структуры ETHERNET_HEADER, IPV4_HEADER или ip4_hdr, IPV6_HEADER или ip6_hdr, TCP_HDR и т.д.

• В Linux — в файлах linux/if_ether.h, linux/ip.h, linux/ipv6.h, linux/tcp.h и т.п. Константы в netinet/in.h.

• В BSD-системах структуры могут быть описаны в разных файлах, например в netinet/ip.h.

Теперь рассмотрим методы, работающие с данными.

Метод PacketPrinter::print_hex_ascii_line() не содержит специфичного для сети кода.

Данные полезной нагрузки распечатывает метод print_payload():

// Распечатать полезную нагрузку пакета.

void PacketPrinter::print_payload(const u_char *payload, int len)

{

int len_rem = len;

// Количество байтов в строке.

constexpr int line_width = 16;

// Счетчик для смещения.

int offset = 0;

const u_char *ch = payload;

if (len <= 0) return;

// Данные, которые умещаются в одной строке.

if (len <= line_width)

{

print_hex_ascii_line(ch, len, offset);

return;

}

Он разбивает их на строки фиксированной длины и выводит как дамп:

// Вывод данных, занимающих несколько строк.

for (;;)

{

// Рассчитать длину.

int line_len = line_width % len_rem;

// Вывести строку.

print_hex_ascii_line(ch, line_len, offset);

// Рассчитать остаток.

len_rem = len_rem — line_len;

// Сдвинуть указатель на оставшиеся для печати данные.

ch = ch + line_len;

offset = offset + line_width;

// Количество символов равно или меньше длины строки?

if (len_rem <= line_width)

{

// Вывести последнюю строку и выйти.

print_hex_ascii_line(ch, len_rem, offset);

break;

}

}

}

Метод got_packet() вызывается при поступлении нового пакета:

void PacketPrinter::got_packet(u_char *args, const pcap_pkthdr *header,

const u_char *packet)

{

(void)args;

(void)header;

std::cout << "\nPacket number: " << packet_count_++ << std::endl;

// Ethernet-заголовок.

const sniff_ethernet *ethernet =

reinterpret_cast<const sniff_ethernet *>(packet);

// Сюда будет записан печатный адрес протокола.

std::string proto_addr;

// Семейство адресов: AF_INET либо AF_INET6.

int network_proto_type;

// Транспортный протокол.

int transport_proto_type;

// Адреса протоколов.

const void *src, *dst;

// Вспомогательные переменные для получения размеров IP-заголовка.

size_t size_ip;

size_t ip_len;

Видно, что метод получил структуру Ethernet-заголовка. После этого он по содержащемуся в нем коду протокола в ether_type выбирает IPv4 или IPv6.

В IPv4 для этого используется поле ip_p:

switch (ntohs(ethernet->ether_type))

{

// IPv4.

case 0x0800:

{

std::cout << "  IPv4 protocol" << std::endl;

proto_addr.resize(INET_ADDRSTRLEN);

network_proto_type = AF_INET;

// Заголовок.

const sniff_ip *ip =

reinterpret_cast<const sniff_ip *>(packet + SIZE_ETHERNET);

// Размер заголовка.

size_ip = ip_hl(ip) * 4;

// Будет использовано для получения размера нагрузки.

ip_len = ntohs(ip->ip_len);

if (size_ip < ip_header_size)

{

std::cout

<< "   * Invalid IP header length: " << size_ip

<< " bytes" << std::endl;

return;

}

transport_proto_type = ip->ip_p;

src = &ip->ip_src;

dst = &ip->ip_dst;

break;

}

В IPv6 идентификатор транспортного протокола содержится в поле next_header:

// IPv6.

case 0x86DD:

{

std::cout << "  IPv6 protocol" << std::endl;

proto_addr.resize(INET6_ADDRSTRLEN);

network_proto_type = AF_INET6;

// Заголовок IPv6.

const sniff_ipv6 *ip =

reinterpret_cast<const sniff_ipv6 *>(packet + SIZE_ETHERNET);

size_ip = sizeof(sniff_ipv6);

ip_len = ntohs(ip->payload_len);

// Поле "следующий заголовок" — смещение заголовка TCP.

transport_proto_type = ip->next_header;

// Адреса.

src = &ip->src;

dst = &ip->dest;

break;

}

default:

{

std::cout

<< "  Unknown EtherType = 0x" << std::hex

<< ntohs(ethernet->ether_type) << std::endl;

return;

}

}

Адреса выводятся на экран:

// Печать адресов.

std::cout

<< std::dec << "    From: "

<< inet_ntop(network_proto_type, src, &proto_addr[0],

proto_addr.size()) << "\n"

<< "    To: " << inet_ntop(network_proto_type, dst, &proto_addr[0],

proto_addr.size())

<< std::endl;

Затем выводится тип вышележащего протокола:

// Определение протокола и печать.

switch (transport_proto_type)

{

case IPPROTO_TCP: std::cout << "    Protocol: TCP" << std::endl;

// Будем выводить только TCP-сегменты.

break;

case IPPROTO_UDP: std::cout << "    Protocol: UDP" << std::endl;

return;

case IPPROTO_ICMP: std::cout << "    Protocol: ICMP" << std::endl;

return;

case IPPROTO_ICMPV6: std::cout << "    Protocol: ICMPv6" << std::endl;

return;

case IPPROTO_IP: std::cout << "    Protocol: IP" << std::endl;

return;

case IPPROTO_IPV6: std::cout << "    Protocol: IPv6" << std::endl;

return;

default:

std::cout

<< "    Protocol: unknown ["

<< transport_proto_type << "]" << std::endl;

return;

}

Как мы уже говорили, в поле транспортного протокола идентификаторы IPPROTO_IP и IPPROTO_IPV6 могут попасть, если выполняется инкапсуляция.

Но в операторе switch выше осуществляется выход из функции в любом случае, когда транспортный протокол не TCP. То есть для упрощения мы не выполняем рекурсивную обработку инкапсулированных пакетов.

Выполним печать содержимого TCP-сегмента:

// Это TCP-сегмент.

// Вычислить смещение TCP-заголовка.

const sniff_tcp *tcp = reinterpret_cast<const sniff_tcp *>(packet +

SIZE_ETHERNET + size_ip);

const int size_tcp = th_off(tcp) * 4;

if (size_tcp < ip_header_size)

{

std::cerr

<< "    * Invalid TCP header length: "

<< size_tcp << " bytes" << std::endl;

return;

}

// Вывести порты.

std::cout

<< "    Src port: " << ntohs(tcp->th_sport) << "\n"

<< "    Dst port: " << ntohs(tcp->th_dport) << std::endl;

// payload — данные пакета.

// Определить смещение данных в TCP-сегменте.

const u_char *payload = static_cast<const u_char *>(packet +

SIZE_ETHERNET + size_ip + size_tcp);

// Вычислить размер данных TCP-сегмента.

const int size_payload = ip_len — (size_ip + size_tcp);

// Вывести дамп полезных данных.

if (size_payload > 0)

{

std::cout << "    Payload (" << size_payload << " bytes):\n";

print_payload(reinterpret_cast<const unsigned char *>(payload),

size_payload);

std::cout << std::endl;

}

}

Теперь определим функцию-обработчик. В ней мы создадим экземпляр класса и вызовем его обработчик:

extern "C"

{

#include <pcap.h>

}

...

#include "packet_printer.h"

// Длина захвата по умолчанию — максимальное количество байтов пакета.

constexpr auto SNAP_LEN = 1518;

// Количество пакетов для захвата.

constexpr auto MAX_PACKET_TO_CAPTURE = 10;

// Обработчик.

static void pcap_callback(u_char *args, const pcap_pkthdr *header,

const u_char *packet)

{

// Статическая переменная здесь просто для того, чтобы не выделять

// память.

static PacketPrinter p_printer;

// Обработать пакет.

p_printer.got_packet(args, header, packet);

};

Работа с libpcap начинается в функции main(), в которой сначала нужно выбрать устройство для захвата данных, если оно не было указано явно:

int main(int argc, const char *const argv[])

{

std::string dev;

// Буфер ошибок Pcap.

std::array<char, PCAP_ERRBUF_SIZE> errbuf;

// Далее идет обработка параметров командной строки, которая не показана.

...

{

pcap_if_t *all_devs_sp = nullptr;

// Если устройство не было задано, найти его.

if (pcap_findalldevs(&all_devs_sp, errbuf.data()) != 0 ||

nullptr == all_devs_sp)

{

std::cerr << "Couldn't find default device: \"" << errbuf.data()

<< "\"" << std::endl;

return EXIT_FAILURE;

}

assert(all_devs_sp->name);

// Получить устройство.

dev = all_devs_sp->name;

// Освободить список.

pcap_freealldevs(all_devs_sp);

}

Выбрав устройство из списка, необходимо освободить список. Затем получим некоторые параметры устройства для печати — библиотека pcap это позволяет — и откроем устройство захвата:

bpf_u_int32 mask;

bpf_u_int32 net;

// Получить номер сети и маску, связанные с устройством захвата.

if (-1 == pcap_lookupnet(dev.c_str(), &net, &mask, errbuf.data()))

{

std::cerr

<< "Couldn't get netmask for device \"" << dev

<< "\": " << errbuf.data() << std::endl;

net = 0 = mask = 0;

}

// Количество принятых пакетов.

constexpr int num_packets = MAX_PACKET_TO_CAPTURE;

// Выражение фильтра: принимать только IP- или IPv6-пакеты.

constexpr char filter_exp[] = "ip or ip6";

// Вывести информацию о "сессии захвата".

std::cout << "Device: " << dev << "\n"

<< "Network mask: " << mask << "\n"

<< "Network: " << net << "\n"

<< "Number of packets: " << num_packets << "\n"

<< "Filter expression: " << filter_exp << std::endl;

// Открыть устройство захвата и получить дескриптор Pcap.

pcap_t *const handle = pcap_open_live(dev.c_str(), SNAP_LEN, 1, 1000,

errbuf.data());

if (nullptr == handle)

{

std::cerr << "Couldn't open device \"" << dev

<< "\": " << errbuf << "!" << std::endl;

return EXIT_FAILURE;

}

С полученным дескриптором устройства мы будем работать дальше. Перед тем как начать захват, убедимся, что устройство ведет себя как Ethernet-адаптер. В обработчике, показанном ранее, мы работаем только с Ethernet-кадрами. Затем скомпилируем фильтр и применим его к хэндлу открытого устройства. Только после этого запустим цикл захвата, передав ему требуемое количество пакетов и обработчик:

int exit_code = EXIT_SUCCESS;

// Скомпилированная программа фильтра.

bpf_program fp{};

try

{

std::stringstream ss;

// Захват производится с Ethernet-совместимого адаптера.

if (pcap_datalink(handle) != DLT_EN10MB)

{

ss << "\"" << dev << "\" is not an Ethernet!" << std::endl;

throw std::logic_error(ss.str());

}

// Скомпилировать выражение фильтра.

if (-1 == pcap_compile(handle, &fp, filter_exp, 0, net))

{

ss << "Couldn't parse filter \"" << filter_exp << "\": "

<< pcap_geterr(handle) << "!" << std::endl;

throw std::logic_error(ss.str());

}

// Применить фильтр.

if (-1 == pcap_setfilter(handle, &fp))

{

ss << "Couldn't install filter \""

<< filter_exp << "\": " << pcap_geterr(handle) << "!"

<< std::endl;

throw std::logic_error(ss.str());

}

// Запустить цикл захвата, передав ему обработчик.

if (PCAP_ERROR == pcap_loop(handle, num_packets, pcap_callback,

nullptr))

{

ss << "Pcap loop failed: " << pcap_geterr(handle) << "!"

<< std::endl;

throw std::logic_error(ss.str());

}

}

Когда захват окончен, необходимо удалить скомпилированный фильтр и закрыть дескриптор устройства захвата:

catch (const std::exception &e)

{

std::cerr << e.what() << std::endl;

exit_code = EXIT_FAILURE;

}

// Очистка.

pcap_freecode(&fp);

pcap_close(handle);

if (!exit_code) std::cout << "\nCapture complete." << std::endl;

return exit_code;

Запустим пример:

➭ sudo ./run build/bin/b01-ch22-pcap-sniffer

Device: wlo1

Network mask: 16777215

Network: 174272

Number of packets: 10

Filter expression: ip or ip6

Packet number: 1

IPv4 protocol

From: 0.0.0.0

To: 255.255.255.255

Protocol: UDP

Packet number: 2

IPv4 protocol

From: 0.0.0.0

To: 255.255.255.255

Protocol: UDP

Packet number: 3

IPv4 protocol

From: 91.105.192.100

To: 192.168.2.13

Protocol: TCP

Src port: 80

Dst port: 51672

Payload (234 bytes):

0

48 54 54 50 2f 31 2e 31  20 32 30 30 20 4f 4b 0d    HTTP/1.1 200 OK.

00016

0a 43 6f 6e 6e 65 63 74  69 6f 6e 3a 20 6b 65 65    ................

...

Видим, что автоматически был выбран Wi-Fi-адаптер и третий захваченный с него пакет, который пришел на интерфейс с IP-адреса 91.105.192.100, содержит фрагмент HTTP-ответа.

В примере сборка кода производится CMake, и функция FindPCAP() в CMakeLists.txt проекта взята из кода Wireshark220.

PcapPlusPlus

Для C++ существует несколько оберток и надстроек над libpcap и аналогами:

• libtins221 — простая и быстрая обертка. Поддерживает C++11, захват Wi-Fi и расшифровку WPA2.

• PcapPlusPlus222 — кросс-платформенная библиотека C++ для захвата, анализа и создания сетевых пакетов. Работает на ОС Linux, FreeBSD, Android, Windows, MacOS.

В примере использования PcapPlusPlus из репозитория библиотеки сначала открывается «устройство захвата», которое в данном случае является файлом дампа PCAP. Затем начинается чтение файла:

#include <iostream>

#include <pcapplusplus/IPv4Layer.h>

#include <pcapplusplus/Packet.h>

#include <pcapplusplus/PcapFileDevice.h>

int main(int argc, char* argv[])

{

// Открыть PCAP-файл для чтения.

pcpp::PcapFileReaderDevice reader("1_packet.pcap");

if (!reader.open())

{

std::cerr << "Error opening the pcap file" << std::endl;

return EXIT_FAILURE;

}

// Прочитать первый пакет из файла.

pcpp::RawPacket rawPacket;

if (!reader.getNextPacket(rawPacket))

{

std::cerr

<< "Couldn't read the first packet in the file"

<< std::endl;

return EXIT_FAILURE;

}

Из каждого пакета, который был прочитан из файла, может быть создан экземпляр класса pcpp::Packet, который отображает свойства пакета.

В примере видно, как проверяется тип и, если это пакет IPv4, печатаются его адреса:

// Разобрать "сырой" пакет и преобразовать в структуры.

pcpp::Packet parsedPacket(&rawPacket);

// Проверить, что это IPv4.

if (parsedPacket.isPacketOfType(pcpp::IPv4))

{

// Получить адрес источника.

pcpp::IPv4Address srcIP = parsedPacket.

getLayerOfType<pcpp::IPv4Layer>()->getSrcIPv4Address();

// Получить адрес назначения.

pcpp::IPv4Address destIP = parsedPacket.

getLayerOfType<pcpp::IPv4Layer>()->getDstIPv4Address();

// Распечатать адреса.

std::cout

<< "Source IP is '" << srcIP

<< "'; Dest IP is '" << destIP << "'"

<< std::endl;

}

// Закрыть файл.

reader.close();

return EXIT_SUCCESS;

}

Библиотека работает не только поверх libpcap, Npcap, WinPcap, но и поверх описанных далее фреймворков DPDK и PF_RING. Кроме того, в ней есть еще несколько интересных дополнительных возможностей:

• Возможность сборки пакетов IPv4 и IPv6 из фрагментов.

• Сборка блоков данных из потока TCP.

• TLS Fingerprinting — идентификация приложений по TLS-рукопожатию.

• Фильтрация пакетов.

• Возможность записи и чтения сжатых PCAP-файлов.

Таким образом, это очень мощная, удобная и при этом простая библиотека.

Pcapy

Pcapy223 — сторонний пакет Python, который позволяет напрямую работать с libpcap и WinPcap. Он предоставляет объектный API, который в основном повторяет интерфейс libpcap, позволяя работать в Python-стиле. Однако начиная с версии Python 3.10, оригинальный Pcapy не поддерживается и не работает. Поэтому существует его более современный вариант — Pcapy-NG224, который предоставляет те же возможности.

Пример обработки дампа с использованием Pcapy-NG:

import pcapy

# Открыть файл дампа.

dump = pcapy.open_offline('dump.pcap')

# Создать дампер, который будет записывать в new_dump.pcap.

dumper = dump.dump_open('new_dump.pcap')

# Заголовок и тело первой записи.

hdr, body = dump.next()

while hdr is not None:

# Обход записей.

dumper.dump(hdr, body)

print(len(body))

hdr, body = dump.next()

Резюме

Для мониторинга трафика, проходящего через интерфейс адаптера, существуют анализаторы трафика, или снифферы.

Снифферы могут быть программными и аппаратными. Аппаратные снифферы часто используются для диагностики сети или сбора данных, например, в целях безопасности.

Снифферы могут перехватывать данные на канальном и физическом уровне. Обычно имеет смысл выполнять перехват всего трафика, проходящего через адаптер. Для этого его переводят в неразборчивый режим или режим мониторинга в случае Wi-Fi-адаптеров.

Обычно данные не анализируются сразу, а в «сыром» виде записываются для последующего анализа в хранилище, например на диск. Стандартом де-факто является формат записи сетевого дампа — PCAP. Этот формат прост и вместе с тем достаточен для хранения сетевых данных. Он реализован в библиотеках, которые предоставляют API для захвата трафика, например Libpcap, Winpcap, Win10Pcap и Npcap.

Инструменты для анализа трафика, такие как tcpdump, ngrep, Wireshark, EtherApe и другие, используют перечисленные библиотеки.

Существуют оболочки для различных языков, позволяющие использовать функциональность libpcap. Некоторые версии библиотек также поддерживают формат PcapNg, который является основным для Wireshark.

Вопросы и задания

1. Что такое снифферы и как они могут быть полезны в разработке сетевых приложений?

2. Что такое «неразборчивый режим» адаптера и для чего требуется переводить адаптер в этот режим?

3. Как перевести адаптер в неразборчивый режим?

4. Каковы отличия неразборчивого режима от мониторингового в Wi-Fi?

5. Какие семейства и типы сокетов можно использовать для перехвата трафика?

6. О чем говорит тип ETH_P_ALL для raw-сокета?

7. Почему, когда в Linux запущен Wireshark или tcpdump, интерфейс находится не в promisc-режиме?

8. Какой формат является стандартом де-факто для хранения захваченного трафика и как он устроен?

9. Какие форматы дампов трафика поддерживает Wireshark? В чем их отличия от формата PCAP?

10. Почему в коде Python сниффера не используются функции для преобразования атрибутов пакета из сетевого порядка байтов?

11. Какие изменения произошли в инструментах мониторинга трафика, начиная с Windows 10?

12. Какие библиотеки могут использоваться для работы с форматом PCAP в C и C++?

13. Как захватить данные из канала, используя libpcap, и какие шаги для этого требуются?

14. Чем удобна библиотека PCap++?

15. Какой пакет можно использовать для обработки PCAP-дампов в Python?

16. Перепишите C++-сниффер, используя библиотеку PСap++.

17. В примере для сниффера на Python:

a) В классе Ipv4Header для простоты мы работаем только с пакетами, имеющими код заголовка 0x0800, или 0x0008 в сетевом порядке байтов. Какой сетевой протокол имеет такой код? Нужен ли тут список кодов и почему? Если нужен, допишите обработку списка кодов.

б) В классе Ipv6Header для простоты мы не стали разбирать опции протокола, доработайте этот класс разбором опций.

в) Для работы с ICMPv6 мы сделали класс Icmp6Header, который унаследован от IcmpHeader. В идеале следует добавить отдельный класс для ICMPv6. Почему наш вариант работает? Создайте отдельный класс для работы с ICMPv6.

г) Замените тип сокетов AF_PACKET на raw-сокеты и добейтесь работоспособности сниффера.

д) Доработайте пример таким образом, чтобы поддерживались протоколы туннелирования, например IP in IP.

18. Измените код из примера по установке Promisc-режима таким образом, чтобы флаг неразборчивого режима устанавливался перед работой приложения и сбрасывался всегда при завершении работы приложения.

19. В примере реализации сниффера на основе libpcap мы ограничились TCP-трафиком, добавьте в него работу с UDP-трафиком.

174 https://en.wikipedia.org/wiki/Bus_analyzer

175 https://en.wikipedia.org/wiki/Monitor_mode

176 https://ask.wireshark.org/question/17077/wireshark-enabled-promisc-mode-but-ifconfig-displays-not/

177 https://github.com/the-tcpdump-group/libpcap/blob/master/pcap-linux.c#L2518

178 https://man7.org/linux/man-pages/man7/packet.7.html

179 https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml

180 https://standards-oui.ieee.org/ethertype/eth.txt

181 https://ru.wikipedia.org/wiki/IP_in_IP

182 https://www.wireshark.org/

183 https://www.gigamon.com/products/deep-observability-pipeline.html

184 https://www.tamos.com/products/commview

185 https://etherape.sourceforge.io/

186 https://linux.die.net/man/8/ngrep

187 https://www.tcpdump.org/manpages/tcpdump.1.html

188 https://wikisec.ru/index.php?title=TCPDUMP_for_Windows

189 https://www.winpcap.org/windump/

190 https://habr.com/ru/news/502546/

191 https://docs.oracle.com/cd/E19253-01/816-5166/6mbb1kqij/index.html

192 https://onotelli.github.io/justniffer/

193 http://netsniff-ng.org/

194 https://man7.org/linux/man-pages/man8/trafgen.8.html

195 https://en.wikipedia.org/wiki/Comparison_of_packet_analyzers

196 https://learn.microsoft.com/en-us/windows/win32/netmon2/network-monitor

197 https://www.microsoft.com/en-in/download/details.aspx?id=4865

198 https://learn.microsoft.com/en-us/windows/win32/netmon2/the-network-monitor-sdk

199 https://learn.microsoft.com/en-us/windows/win32/netmon2/about-capture-filters

200 https://learn.microsoft.com/en-us/windows/win32/netmon2/irtc

201 https://learn.microsoft.com/en-us/windows/win32/netmon2/idelaydc

202 https://learn.microsoft.com/en-us/windows/win32/netmon2/iesp

203 https://learn.microsoft.com/en-us/windows/win32/netmon2/network-monitor-blobs

204 https://learn.microsoft.com/en-us/windows/win32/netmon2/monitors

205 https://learn.microsoft.com/en-us/windows/win32/netmon2/imonitor

206 https://learn.microsoft.com/en-us/windows/win32/netmon2/experts

207 https://learn.microsoft.com/en-us/windows/win32/netmon2/parsers

208 https://winitpro.ru/index.php/2020/07/07/packet-monitor-pktmon-sniffer-traffika-windows/

209 https://en.wikipedia.org/wiki/Pcap

210 https://github.com/the-tcpdump-group/libpcap

211 https://ee.lbl.gov/

212 https://www.winpcap.org/

213 https://learn.microsoft.com/en-us/windows-hardware/drivers/network/overview-of-ndis-versions

214 http://www.win10pcap.org/

215 https://learn.microsoft.com/en-us/windows-hardware/drivers/network/introduction-to-ndis-6-0

216 https://npcap.com/

217 https://wiki.wireshark.org/Development/LibpcapFileFormat

218 https://github.com/HoneProject/Linux-Sensor/wiki/Augmented-PCAP-Next-Generation-Dump-File-Format

219 https://npcap.com/guide/wpcap/pcap.html

220 https://github.com/wireshark/wireshark/blob/master/cmake/modules/FindPCAP.cmake

221 https://libtins.github.io/

222 https://github.com/seladb/PcapPlusPlus

223 https://github.com/helpsystems/pcapy

224 https://github.com/stamparm/pcapy-ng/

Глава 23. TUN/TAP-интерфейсы. Техника Kernel bypass

Я думаю, феномен Linux восхитителен, потому что он прочно стоит на той основе, которую предоставил Unix. Linux кажется одним из самых здоровых прямых деривативов Unix.

Деннис M. Ритчи, из интервью на LinuxFocus.org, 1999

Введение

Сетевые устройства усложняются, и в них удобно использовать ОС общего назначения, такие как Linux.

Но сетевой стек ОС, реализованный в ядре, и путь от драйвера к прикладному уровню недостаточно быстры для обработки трафика от адаптеров, начиная с 10Gb/s225. То есть в высокопроизводительных сетевых устройствах, например маршрутизаторах, DLP-системах и прочих, обычный стек Linux и многих других ОС использовать уже невозможно.

Подобная проблема существует не только в Linux. Многие ОС предоставляют универсальный сетевой стек и пользовательский интерфейс на основе сокетов. Задача стеков — не столько высокая производительность, сколько обеспечение универсальности и совместимости, а также поддержка разнообразных сетевых протоколов и устройств.

Чтобы исключить из работы медленное ядро, было предложено работать с адаптерами напрямую в пространстве пользователя. Эту технику называют kernel bypass — «обход ядра».

Сетевая подсистема Linux медленная во многом потому, что пакеты копируются минимум два раза до того, как их начнет обрабатывать приложение.

Сначала пакет копируется из DMA-области памяти в буфер ядра, затем оттуда в буфер пользователя. На одно копирование требуется от 500 до 2000 тактов процессора. Мало того, копирования в дальнейшем приводят к промахам кэша CPU, что еще сильнее замедляет работу. Поэтому множественные копирования небольших по размеру пакетов приводят к снижению производительности.

Чтобы этого избежать, в сторонних решениях используется отображение DMA (direct memory access, прямой доступ к памяти) в пространство пользователя, что позволяет не прибегать к копированию. Но это требует поддержки сетевой картой и ее драйвером. Также возможно отображение буфера ядра в пространство пользователя, то есть одно копирование.

Основные преимущества работы в пространстве пользователя:

• Упрощение разработки.

• Исключение частого копирования данных из пространства ядра в пространство пользователя и обратно.

• Возможность использовать все средства, предоставляемые ОС на прикладном уровне.

• Возможность перенести часть вычислений на ускорители, не разрабатывая для этого сопряжения между драйверами.

• Простота смены алгоритмов обработки.

• Потоковая обработка данных взамен управляемой прерываниями, как в ядре.

Мы познакомимся с механизмами обработки высокоскоростного трафика, начиная с TUN/TAP-интерфейсов, которые используются для туннелирования, а также встроенных механизмов zero-copy и заканчивая различными вариантами In-Kernel FastPath, ускоряющими обработку пакетов. Кроме того, мы немного коснемся фреймворков DPDK, PF_RING и Snabb.

Вдобавок мы поверхностно рассмотрим модель WSD, с которой пользователь может работать в ОС Windows через интерфейс WinSock, но без подключения всего стека протоколов.

Также затронем программно-аппаратные комплексы, такие как SmartNIC, или умные сетевые карты, и язык программирования P4.

TUN/TAP

Рис. 23.1. Интерфейс TUN/TAP

TUN/TAP — это интерфейс операционной системы для создания сетевых интерфейсов, управляемых из пространства пользователя. Он изображен на рис. 23.1. Обычно он используется для реализации VPN. Такие пакеты, как OpenVPN, l2tpns и OpenSSH, в режиме туннелирования используют данный интерфейс.

TUN/TAP реализован в Linux, FreeBSD, OpenBSD и некоторых других BSD-системах, а также в Solaris. Интерфейс предоставляет виртуальные сетевые устройства — TUN и TAP. Эти устройства реализованы в ядре и позволяют приложениям создавать новые сетевые интерфейсы. В Linux их реализация содержится в  модуле ядра, который называется tun.

Создаваемые интерфейсы можно рассматривать как Ethernet-устройства «точка-точка», пригодные для отправки и приема пакетов напрямую из пользовательского пространства.

Администрировать TUN/TAP-устройства возможно, используя команду ip tuntap.

Рис. 23.2. Типичная реализация VPN

В Linux типичная реализация VPN использует TAP-устройство, то есть создает виртуальные интерфейсы Ethernet. Показанный на рис. 23.2 TAP-интерфейс с настроенным IP представляет собой точку подключения к виртуальной сети.

Все пакеты с адресами, соответствующими этой сети, отправляются в данный интерфейс. Если же он настроен как шлюз по умолчанию, через него будет отправлен весь трафик.

Алгоритм работы такого VPN следующий:

1. На интерфейсе tap0 настроен IP-адрес. Поэтому всякий раз, когда ядро отправляет пакет на tap0, он передается связанному приложению.

2. Приложение шифрует, сжимает и отправляет его получателю, используя TCP или UDP через физический интерфейс.

3. На стороне приемника приложение получает данные из сети по физическому интерфейсу, затем распаковывает и расшифровывает полученные данные и записывает пакеты в TAP-устройство.

4. Ядро обрабатывает пакет так, как будто он пришел с реального физического интерфейса, то есть из сети, и в результате отправляет его нужному приложению. Например, серверу, который слушает порт.

Так организуется канал, частью которого является приложение VPN.

Различие между TUN и TAP

Основное различие — это работа на разных уровнях OSI, как показано на рис. 23.3.

TUN — интерфейс сетевого уровня 3. Он работает с IP-дейтаграммами. Это интерфейс «точка-точка».

TAP — интерфейс канального уровня 2. По умолчанию он работает с Ethernet-кадрами. Иными словами — это виртуальный Ethernet-адаптер.

Рис. 23.3. TUN и TAP в сетевом стеке

TAP-интерфейс по умолчанию имеет тип ARPHRD_ETHER, а TUN — ARPHRD_NONE. Другие константы типа устройства определены в linux/if_arp.h.

Создание устройства

Сначала необходимо открыть файл /dev/net/tun — управляющий интерфейс. Через его дескриптор будет получено новое виртуальное сетевое устройство. Для создания сетевых устройств или для подключения к сетевым устройствам, не принадлежащим данному пользователю, требуется привилегия CAP_NET_ADMIN.

Вызов, запрашивающий виртуальный сетевой интерфейс, — TUNSETIFF. Его параметром является уже знакомая нам структура ifreq. Если поле ifr_name не пусто, вызов ioctl создает либо новый виртуальный сетевой интерфейс с выбранным именем, либо открывает существующий.

Если поле ifr_name пусто, будет создан новый интерфейс, имя для которого сгенерировано автоматически, как tunXX или tapXX, и сохранено в этом же поле.

Если при создании TUN-интерфейса установить опцию IFF_NO_PI, то есть NO Packet Information, в поле ifr_flags заголовок будет отсутствовать, а версия IP будет выводиться из номера версии IP в пакете.

В случае установки опции IFF_NO_PI TUN-интерфейс будет отбрасывать все не IPv4- или IPv6-пакеты, фильтруя их по первому байту, с ошибкой EINVAL.

В случае же, если опция не установлена, появляется возможность отправлять IP-пакеты версии, указанной пользователем.

Общие вызовы и опции

Некоторые полезные ioctl:

• TUNSETIFF — запросить создание нового устройства или установить параметры существующего. Это один из главных ioctl. В поле ifr_flags задается тип интерфейса и некоторые другие флаги. Тип может быть следующим:

• IFF_TUN — создать TUN-интерфейс.

• IFF_TAP — создать TAP-интерфейс.

• TUNGETIFF — получить структуру ifreq, содержащую имя и флаги устройства.

• TUNSETDEBUG — включить или выключить отладку. Будет работать, если ядро скомпилировано с флагом #define TUN_DEBUG.

• TUNSETPERSIST — установить или сбросить TUN_PERSIST.

• TUNSETOWNER — установить uid владельца устройства.

• TUNSETGROUP — установить gid владельца устройства.

• TUNGETFEATURES — возвращает флаги, поддерживаемые интерфейсом.

• TUNSETOFFLOAD — позволяет выполнять некоторые сетевые вычисления в пользовательском пространстве так, будто они выполняются сетевым адаптером:

• TUN_F_CSUM — передавать пакеты без контрольной суммы. В этом случае ее должен рассчитывать пользовательский код. Лучше устанавливать всегда, чтобы не получать случайных ошибок на старых версиях ядер.

• TUN_F_TSO4 — TSO или TCP Segmentation Offload для пакетов IPv4. При включении часть обработки может выполняться пользовательским кодом.

• TUN_F_TSO6 — TSO или TCP Segmentation Offload для пакетов IPv6.

• TUN_F_TSO_ECN — пользовательский код может выполнять TSO с битами ECN, то есть без явного уведомления о перегрузке.

• TUN_F_USO4 и TUN_F_USO6 — разгрузка сегментации UDP для пакетов IPv4 и IPv6 соответственно.

• TUN_F_UFO — пользовательский код может обрабатывать пакеты UFO, или UDP Fragmentation Offload, то есть собирать дейтаграммы из фрагментов. Опция устарела и не рекомендуется к использованию.

• TUNSETIFINDEX — установить индекс виртуального интерфейса.

• TUNGETSNDBUF — получить текущий буфер отправки.

• TUNSETSNDBUF — установить текущий буфер отправки.

• SIOCSIFHWADDR — установить «аппаратный адрес» интерфейса, причем это можно сделать даже на работающем устройстве.

• SIOCGIFHWADDR — получить «аппаратный адрес» интерфейса.

• TUNGETVNETHDRSZ — получить размер заголовка, используемого при установке TUN_VNET_HDR.

• TUNSETVNETHDRSZ — задать размер заголовка, используемого при установке TUN_VNET_HDR. В качестве параметра используется структура virtio_net_hdr_mrg_rxbuf.

• TUNSETQUEUE — управляет добавлением очередей. Будет работать, только если при создании устройства был установлен флаг IFF_MULTI_QUEUE. Флаги в ifr_flags, которые управляют поведением:

• IFF_ATTACH_QUEUE — добавить очередь.

• IFF_DETACH_QUEUE — удалить очередь.

• TUNSETCARRIER — установить состояние несущей интерфейса.

• TUNGETDEVNETNS — получить дескриптор сетевого пространства имен, которому принадлежит интерфейс.

Флаги, которые устанавливаются в ifreq.flags при вызове TUNSETIFF:

• IFF_MULTI_QUEUE — позволяет открывать tun- или tap-устройство через TUNSETIFF несколько раз, указывая одно и то же имя. В результате через разные дескрипторы несколько разных потоков смогут обрабатывать данные параллельно, используя одно устройство, которое будет теперь содержать несколько очередей кадров или пакетов.

• IFF_TUN_EXCL — гарантирует создание нового устройства, а не открытие существующего постоянного. Вернет EBUSY, если устройство существует.

• IFF_NO_CARRIER — не устанавливать флаг CARRIER интерфейса. Потом возможно установить через вызов TUNSETCARRIER.

• IFF_NOFILTER — фильтр Ethernet-адресов не установлен. Флаг только для чтения.

• IFF_ONE_QUEUE — устарел. В современных системах он всегда включен.

• IFF_PERSIST — позволяет устройству продолжать существовать, даже когда последний связанный дескриптор закрыт. Доступен только для чтения. Чтобы установить или убрать этот флаг, необходимо открыть устройство, выключить опцию через ioctl TUNSETPERSIST и после этого закрыть устройство.

• IFF_VNET_HDR — включает управление GSO, или Generic Segmentation Offload. Добавляет структуру virtio_net_hdr для указания GSO и контрольной суммы. GSO предполагает, что некоторые функции обработки пакетов будут вынесены из ядра. В данном случае они будут выполняться приложениями.

• IFF_NAPI и IFF_NAPI_FRAGS — использовать NAPI вызовы. Это при выполнении GRO может увеличивать производительность.

Форматы данных и опции TUN

В TUN приложение должно отправлять IP-пакеты, и читать оно будет тоже IP-пакеты.

По умолчанию каждый пакет в TUN-интерфейсе предваряется заголовком:

struct tun_pi

{

// 2 байта флагов.

__u16  flags;

// 2 байта — код протокола из списка EtherType.

__be16 proto;

};

В Linux структура определена в /usr/include/linux/if_tun.h.

Ее атрибуты:

• flags — различные флаги. В Linux определен только один флаг: TUN_PKT_STRIP. Он устанавливается ядром, если длина предоставленного буфера недостаточна и часть данных была отброшена.

• proto — EtherType-код протокола в сетевом порядке байтов. Обычно ETH_P_IP или ETH_P_IPV6.

Отключить это поведение возможно опцией IFF_NO_PI, задаваемой в поле ifr_flags.

Форматы данных и опции TAP

Обмен выполняется «пользовательскими» Ethernet-кадрами в случае, если TAP-устройство имеет тип Ethernet. Тип можно изменить, используя вызов TUNSETLINK. Например, сделать виртуальным Infiniband либо ATM-адаптером:

ioctl(dev_fd, TUNSETLINK, ARPHRD_ATM);

Изменения будут несущественными, но команда ip addr покажет тип интерфейса link/atm.

Собственно, для TUN-интерфейса можно сделать то же самое. Интерфейс должен быть выключен, иначе возникнет ошибка EBUSY.

Специфичные для TAP-интерфейсов ioctl:

• TUNSETTXFILTER — установить или сбросить фильтр Ethernet-адресов уровня драйвера. Принимает структуру tun_filter.

• TUNATTACHFILTER — установить фильтр BPF. Фильтр может быть только один.

• TUNDETACHFILTER — сбросить фильтр BPF.

• TUNSETFILTEREBPF — установить фильтр eBPF на TAP-устройство.

• TUNSETSTEERINGEBPF — установить eBPF-программу для выбора очереди от TAP интерфейса к приложению. Необходимо, если требуется привязать некоторый трафик, например, к потоку в приложении.

Структура фильтра:

struct tun_filter

{

// Может иметь значение только TUN_FLT_ALLMULTI.

__u16 flags;

// Количество адресов.

__u16 count;

// Адреса интерфейсов.

__u8  addr[0][ETH_ALEN];

};

Флаг может иметь только одно значение: TUN_FLT_ALLMULTI — принимать все multicast-пакеты.

Пример использования

Реализуем набор классов и приложение, работающее как с TUN-, так и с TAP-интерфейсами. Каждый тип интерфейса будет обрабатываться своим классом, а контроллер позволит их создавать и удалять.

Также контроллер задает имя, если оно было передано, в описывающую интерфейс структуру:

class TunTapController

{

public:

// Размер MAC-адреса.

static constexpr auto mac_size = 6;

public:

Tap open_tap(const std::optional<std::string> &dev_name = std::nullopt)

const

{

return std::move(internal_open<Tap>(dev_name));

}

Tun open_tun(const std::optional<std::string> &dev_name = std::nullopt)

const

{

return std::move(internal_open<Tun>(dev_name));

}

private:

template<class T>

T internal_open(const std::optional<std::string> &dev_name) const

{

// Открыть управляющее интерфейсами устройство.

int dev_fd = open("/dev/net/tun", O_RDWR);

if (dev_fd < 0)

{

throw std::system_error(errno, std::system_category(),

"Opening interface");

}

ifreq ifr = {0};

// Если имя задано, скопировать его в ifr.

if (dev_name.has_value())

{

if (dev_name->size() >= IFNAMSIZ)

{

throw std::logic_error("Incorrect name size");

}

std::copy(dev_name->begin(), dev_name->end(), ifr.ifr_name);

}

// Остальное сделает конструктор класса конкретного интерфейса.

return T(dev_fd, ifr);

}

};

Реальный ioctl TUNSETIFF будут выполнять конструкторы сетевых интерфейсов. Для создания TUN-интерфейса устанавливается флаг IFF_TUN. Кроме того, для TUN-интерфейса добавляются опции:

class Tun : public TunTapNetworkInterface

{

public:

Tun(int ni_desc, ifreq &ifr) :

TunTapNetworkInterface::TunTapNetworkInterface(ni_desc)

{

// Это — TUN.

// Сейчас флаг IFF_NO_PI устанавливается безусловно.

ifr.ifr_flags = IFF_TUN | IFF_NO_PI;

if (!set_if_features(ifr))

{

throw std::system_error(errno, std::system_category(),

"Can't set interface features");

}

// Запросить интерфейс.

perform_ioctl(*this, TUNSETIFF, &ifr, "TUNSETIFF ioctl failed");

}

Tun(Tun &&) = default;

std::string type() const override { return "tun"; }

Tun &operator=(const Tun &) = delete;

Tun &operator=(Tun &&other) = default;

protected:

bool set_if_features(ifreq &ifr) noexcept

{

unsigned int features = 0;

// Получить возможности, которые поддерживает интерфейс.

if (-1 == ioctl(*this, TUNGETFEATURES, &features))

{

return false;

}

// Проверка и установка флага, реального эффекта данный флаг не имеет.

if (features & IFF_ONE_QUEUE)

{

ifr.ifr_flags |= IFF_ONE_QUEUE;

}

return true;

}

};

Функция perform_ioctl() выполняет некоторые ioctl, проверяет результат и генерирует исключение в случае ошибки:

int perform_ioctl(int fd, const int call_id, void *result,

const std::string &msg)

{

const auto ioctl_res = ioctl(fd, call_id, result);

if (-1 == ioctl_res)

{

throw std::system_error(errno, std::system_category(), msg);

}

return ioctl_res;

}

В конструкторе TAP-интерфейса вызывается ioctl TUNSETIFF с флагом IFF_TAP:

class Tap : public TunTapNetworkInterface

{

public:

Tap(int ni_desc, ifreq &ifr) :

TunTapNetworkInterface::TunTapNetworkInterface(ni_desc)

{

// Это — TAP.

ifr.ifr_flags = IFF_TAP;

perform_ioctl(*this, TUNSETIFF, &ifr, "TUNSETIFF ioctl failed");

}

Tap(Tap &&) = default;

const std::string type() const override { return "tap"; }

Tap& operator=(Tap &&) = default;

Tap &operator=(const Tap &) = delete;

public:

// MAC можно установить только для TAP-интерфейса.

void set_hw_addr(const uint8_t hw_addr[TunTapNetworkInterface::mac_size])

{

// Сначала нужно получить структуру, описывающую интерфейс.

ifreq ifr = get_iff();

std::copy(hw_addr, hw_addr + TunTapNetworkInterface::mac_size,

&ifr.ifr_hwaddr.sa_data[0]);

// Новый сокет.

int sock = socket(AF_INET, SOCK_DGRAM, 0);

if (sock < 0)

{

throw std::system_error(errno, std::generic_category(),

"Can't create socket");

}

// Установка адреса только для Ethernet.

ifr.ifr_hwaddr.sa_family = ARPHRD_ETHER;

try

{

// Вызов ioctl для установки адреса.

perform_ioctl(sock, SIOCSIFHWADDR, &ifr,

"Can't set interface address");

}

catch (const std::exception &e)

{

std::cerr << e.what() << std::endl;

close(sock);

throw;

}

close(sock);

}

};

Базовый класс не представляет особого интереса, за исключением некоторых общих методов, таких как получение и установка имени сетевого интерфейса, а также методов его преобразования:

class TunTapNetworkInterface

{

public:

static const auto mac_size = 6;

public:

explicit TunTapNetworkInterface(int ni_desc) : fd_(ni_desc) {}

TunTapNetworkInterface(TunTapNetworkInterface &&other)

{

fd_ = other.reset();

}

TunTapNetworkInterface& operator=(TunTapNetworkInterface &&other)

{

fd_ = other.reset();

return *this;

}

virtual ~TunTapNetworkInterface()

{

if (-1 != fd_) close(fd_);

}

virtual const std::string type() const = 0;

public:

// Интерфейс не копируемый, поэтому конструктор

// и операторы присваивания (в наследниках) удаляются.

// Два разных объекта могут содержать только разные интерфейсы.

// С разными именами, дескрипторами.

TunTapNetworkInterface(const TunTapNetworkInterface &) = delete;

Сброс и преобразование к типу дескриптора, то есть к int:

public:

// Преобразование в дескриптор.

operator int() const { return fd_; }

// После сброса дескриптора объект больше им не владеет и не закроет

// его при разрушении.

int reset()

{

const int fd = fd_;

fd_ = -1;

return fd;

}

Методы получения и установки имени сетевого интерфейса:

std::string get_name() const

{

// Получение имени данного TUN/TAP-интерфейса.

auto ifr{std::move(get_iff())};

return ifr.ifr_name;

}

std::string set_name(const std::string &new_name)

{

if (new_name.size() >= IFNAMSIZ)

{

throw std::logic_error("Incorrect name size");

}

// Чтобы выполнить ioctl, необходим обычный сокет.

// Дескриптор TUN/TAP не подходит.

int sock = socket(PF_INET, SOCK_DGRAM, 0);

if (sock < 0)

{

throw std::system_error(errno, std::system_category(),

"Opening socket");

}

// Получить текущее имя данного интерфейса.

const auto old_name = std::move(get_name());

ifreq ifr = {0};

// Старое имя.

std::copy(old_name.begin(), old_name.end(), ifr.ifr_name);

// Новое имя.

std::copy(new_name.begin(), new_name.end(), ifr.ifr_newname);

try

{

perform_ioctl(sock, SIOCSIFNAME, &ifr, "SIOCSIFNAME failed");

}

catch(...)

{

close(sock);

throw;

}

close(sock);

// Перезапросить новое имя.

return get_name();

}

Метод для установки типа интерфейса и служебный метод, необходимый, чтобы получать свойства интерфейса:

void set_interface_type(unsigned int type = ARPHRD_INFINIBAND)

{

// Пример установки типа интерфейса.

perform_ioctl(fd_, TUNSETLINK, &type, "TUNSETLINK ioctl failed");

}

protected:

ifreq get_iff() const

{

// Служебный метод для получения структуры описания интерфейса.

ifreq ifr = {0};

perform_ioctl(fd_, TUNGETIFF, &ifr, "TUNGETIFF ioctl failed");

return ifr;

}

private:

int fd_;

};

Теперь посмотрим, как с этим работать. Сначала для получения интерфейсов создадим контроллер. Используя его, мы получаем объект для нужного интерфейса.

Обмен данными выполняется выполняется с помощью функций обычного файлового ввода-вывода:

• write() — отправляет кадр или пакет на виртуальный сетевой интерфейс.

• read() — получает кадр или пакет от виртуального сетевого интерфейса.

• select() — работает так же, как и для обычного дескриптора сокета, что подробно будет описано в книге 2.

Пример создания TAP-интерфейса, а также работы с ним реализован прямо в функции main():

int main(int argc, const char * const argv[])

{

// Буфер для приема данных.

constexpr auto buffer_size = 1600;

// Контроллер, управляющий созданием интерфейсов.

const TunTapController controller;

try

{

// Открыть новый TAP-интерфейс.

Tap tt_iface{std::move((argc > 1) ?

controller.open_tap(std::string(argv[1])) :

controller.open_tap())};

auto &&dev_name = std::move(tt_iface.get_name());

std::cout

<< "Device " << dev_name << " was opened." << std::endl;

std::vector<char> buf(buffer_size);

int bytes_count;

// Обратите внимание, что MAC для TAP-интерфейса должен начинаться

// с 0x12.

uint8_t mac_addr[] = {0x12, 0x10, 0x20, 0x30, 0x40, 0x50};

// Установить новый адрес.

tt_iface.set_hw_addr(mac_addr);

// Чтение данных из созданного интерфейса.

while (true)

{

std::cout << "Waiting for data..." << std::endl;

const bytes_count = read(tt_iface, &buf[0], buf.size());

std::cout

<< "Read "

<< bytes_count << " bytes "

<< "from " << dev_name << "." << std::endl;

}

}

catch (const std::exception &e) { ... ; }

return EXIT_SUCCESS;

}

Когда программа закрывает последний файловый дескриптор, связанный с интерфейсом TUN/TAP, система уничтожает интерфейс и все связанные маршруты, если для интерфейса не была включена опция TUN_PERSIST.

Теперь запустим пример:

➭ sudo build/bin/b01-ch23-tun-example

Назначим интерфейсу адрес, включим его и запустим ping:

➭ sudo ip a add 10.20.30.40 dev tap0

➭ sudo ip link set up tap0

➭ ping 10.20.30.40

PING 10.20.30.40 (10.20.30.40) 56(84) bytes of data.

64 bytes from 10.20.30.40: icmp_seq=1 ttl=64 time=0.092 ms

...

Видим, что данные пошли:

➭ sudo build/bin/b01-ch23-tun-example

Device tap0 was opened.

Waiting for data...

Read 94 bytes from tap0.

Waiting for data...

Read 66 bytes from tap0.

...

Read 74 bytes from tap0.

Waiting for data...

TUN/TAP-адаптеры в других ОС

Широко распространенные пакеты, такие как OpenVPN и Wireguard, строятся вокруг виртуальных сетевых интерфейсов. Чтобы эти пакеты работали в других ОС, их разработчики поставляют драйверы, реализующие интерерфейсы.

Например, OpenVPN впервые добавили такой драйвер в ОС Windows.

Для MacOS также реализован драйвер от OpenVPN. Также в MacOS есть достаточно старый драйвер MacVTap226 и драйвер tuntaposx, который более не поддерживается и сейчас является частью проекта Tunnelblick — MacOS-интерфейса OpenVPN.

Существует даже отдельный драйвер TUN/TAP для Solaris.

OpenVPN позволяет создавать и настраивать TUN/TAP-интерфейс через параметр mktun: openvpn --mktun.

Для установки параметров TUN/TAP в ОС Windows предлагается использовать Netsh:

netsh interface ip set address my-tap static 10.3.0.1 255.255.255.0

Работа с драйвером TUN/TAP в ОС Windows отличается. В частности, разными ioctl, как по составу, так и по кодам. Поэтому существуют кросс-платформенные библиотеки, унифицирующие интерфейс:

• Libtuntap227 — относительно старая библиотека, реализованная на C. Поддерживает Linux, BSD-системы, MacOS X, ОС Windows.

• UniversalTunTapDriver228 — библиотека C#, поддерживающая основные операции с TUN/TAP-интерфейсами на Linux и Windows.

• Tunctl229 — API для Erlang. Работает на FreeBSD, Linux и MacOS X. На MacOS X требует драйвер tuntaposx.

• Пакет TUN/TAP для Go230 поддерживает Linux и MacOS X.

Вообще библиотеки, поддерживающие TUN/TAP, реализованы для многих языков.

Пример использования TUN/TAP на Python

В Python работа с TUN/TAP-интерфейсами реализована в пакете Scapy. Пакет Scapy подробнее будет рассмотрен в главе 25 этой книги и в книге 3.

Работа с TUN/TAP в Scapy реализована в модуле layers/tuntap.py полностью на Python, без использования модулей на C. Интерфейс TUN/TAP требует файлового ввода-вывода и вызова ioctl, то есть работать с ним легко можно почти на любом распространенном языке программирования.

Дальнейшие действия выполняются из консоли Scapy. Создание интерфейса:

>>> t = TunTapInterface('tun1')

Естественно, должен быть загружен модуль tun. Далее необходимо поднять интерфейс и выполнить назначение адресов через команду ip либо ifconfig, как было показано выше. Интерфейс Scapy этого сделать не позволяет.

Теперь можно работать с интерфейсом из Scapy. Например, создать ICMP-ответчик, который будет возвращать ICMP REPLY, обеспечивая работу ping:

>>> am = t.am(ICMPEcho_am)

>>> am()

am — это объект класса AnsweringMachine, возвращенный методом am() класса scapy.SimpleSocket, от которого и унаследован класс TunTapInterface.

Внимание! Требуется Scapy версии 2.5 и выше, так как в более ранних версиях указанная функциональность отсутствует.

Простой сторонний пакет python-pytuntap231 позволяет создавать устройства TUN/TAP и работать с ними как в Linux, так и в ОС Windows:

from tuntap import TunTap

# Создать TUN.

tun = TunTap(nic_type='Tun', nic_name='tun0')

print(tun.name, tun.ip, tun.mask)

# Создать TAP.

tap = TunTap(nic_type='Tap', nic_name='tap0')

# Установить параметры.

tap.config(ip='192.168.1.10', mask='255.255.255.0', gateway='192.168.1.254')

print(tap.mac)

# Обмен данными.

buf = tun.read(10)

tun.write(buf)

# Закрыть.

tap.close()

tun.close()

Мы не рекомендуем данный пакет к использованию: он не развивается и к тому же не очень грамотно реализован. Например, удаление интерфейсов в нем выполняется не через вызов API, а через запуск команды ip tuntap delete в Linux, а установка адреса в ОС Windows — через запуск netsh interface ip set.

Тем не менее пакет интересен тем, что он прост, и в классе WinTap — реализации для ОС Windows — хорошо видно, как производится работа с Windows-драйвером.

Виден набор ioctl:

class WinTap(Tap):

def __init__(self, nic_type):

super().__init__(nic_type)

# Id устройства для управления драйвером.

self.component_id = 'tap0901'

# Ветка реестра, где будет производиться его поиск.

self.adapter_key = (

r'SYSTEM\CurrentControlSet\Control\Class'

r'\{4D36E972-E325-11CE-BFC1-08002BE10318}'

)

# Набор API для Windows-драйвера.

self.TAP_ioctl_GET_MAC = self._TAP_CONTROL_CODE(1, 0)

self.TAP_ioctl_GET_VERSION = self._TAP_CONTROL_CODE(2, 0)

self.TAP_ioctl_GET_MTU = self._TAP_CONTROL_CODE(3, 0)

self.TAP_ioctl_GET_INFO = self._TAP_CONTROL_CODE(4, 0)

self.TAP_ioctl_CONFIG_POINT_TO_POINT = self._TAP_CONTROL_CODE(5, 0)

self.TAP_ioctl_SET_MEDIA_STATUS = self._TAP_CONTROL_CODE(6, 0)

self.TAP_ioctl_CONFIG_DHCP_MASQ = self._TAP_CONTROL_CODE(7, 0)

self.TAP_ioctl_GET_LOG_LINE = self._TAP_CONTROL_CODE(8, 0)

self.TAP_ioctl_CONFIG_DHCP_SET_OPT = self._TAP_CONTROL_CODE(9, 0)

self.TAP_ioctl_CONFIG_TUN = self._TAP_CONTROL_CODE(10, 0)

...

self.buffer =  win32file.AllocateReadBuffer(2000)

def _TAP_CONTROL_CODE(self,request, method):

return self._CTL_CODE(34, request, method, 0)

def _CTL_CODE(self,device_type, function, method, access):

return (device_type << 16) | (access << 14) | (function << 2) |

method;

Для создания устройства в реестре выполняется поиск его GUID, который затем передается функции CreateFile():

def _get_device_guid(self):

# Далее открывается ветка, чтобы найти требуемое устройство

with reg.OpenKey(reg.HKEY_LOCAL_MACHINE, self.adapter_key) \

as adapters:

try:

for i in range(10000):

key_name = reg.EnumKey(adapters, i)

with reg.OpenKey(adapters, key_name) as adapter:

try:

component_id = reg.QueryValueEx(adapter,

'ComponentId')[0]

if component_id == self.component_id:

regid = reg.QueryValueEx(adapter,

'NetCfgInstanceId')[0]

return regid

...

def create(self):

guid = self._get_device_guid()

# Создание устройства.

self.handle = win32file.CreateFile('\\\\.\\Global\\%s.tap' % guid,

win32file.GENERIC_READ | win32file.GENERIC_WRITE, 0,

None, win32file.OPEN_EXISTING,

win32file.FILE_ATTRIBUTE_SYSTEM | win32file.FILE_FLAG_OVERLAPPED,

None

)

if self.handle:

return self

else:

return None

Управление осуществляется путем вызовов к драйверу с использованием функции DeviceIoControl():

def config(self,ip,mask,gateway="0.0.0.0"):

self.ip = ip

self.mask = mask

self.gateway = gateway

try:

code = b'\x01\x00\x00\x00'

result =

win32file.DeviceIoControl(self.handle,

self.TAP_ioctl_SET_MEDIA_STATUS,

code, 512, None)

...

Чтение и запись в ОС Windows также производятся через файловый API: функции ReadFile() и WriteFile().

Часть API была рассмотрена в главах, посвященных ОС Windows; что касается прочих API, они подробно описаны как в MSDN, так и в литературе по разработке под ОС Windows.

Виртуальные интерфейсы возможно использовать для реализации практически любых протоколов на уровне пользователя без самостоятельной реализации модулей ядра.

Zero-copy

Механизм zero-copy, опции которого были рассмотрены в главе 8, позволяет обойтись без копирования данных между пространством пользователя и ядра, выделяя буфер, доступный как из ядра, так и из пространства пользователя.

О том, что в буфер, общий между ядром и пользовательским пространством, разрешено записать данные, ядро уведомляет через очередь ошибок сокета:

const int sock_fd{socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)};

if (!sock_fd) return EXIT_FAILURE;

int one = 1;

// Процесс должен сначала сигнализировать о включении режима.

if (setsockopt(sock_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))

throw std::system_error(errno, std::system_category(), "setsockopt");

...

std::string buf{"test data"};

auto c_i = socket_wrapper::get_client_info("localhost", "12345");

// Подключиться к абоненту.

if (-1 == connect(sock_fd, c_i->ai_addr, c_i->ai_addrlen))

{

throw std::system_error(errno, std::system_category(), "connect");

}

// Отправить данные, задействовав механизм zero-copy.

if (send(sock_fd, buf.data(), buf.size(), MSG_ZEROCOPY) != buf.size())

throw std::system_error(errno, std::system_category(), "send");

// Инициализировать структуру дескриптором сокета.

pollfd pfd = {.fd = sock_fd, .events = 0};

// Опрос, poll() будет ожидать событий.

if (poll(&pfd, 1, -1) != 1 || !(pfd.revents & POLLERR))

throw std::system_error(errno, std::system_category(), "poll");

Внимание! Процесс не должен немедленно перезаписывать буфер после возврата из системного вызова. Это может привести к повреждению потока данных программы.

Функцию poll() мы изучим в книге 2. Сейчас достаточно знать, что она будет ожидать на сокете новые данные или ошибку. На практике эффективнее не ждать уведомлений, а выполнять чтение, не блокируясь, на каждую пару вызовов sendmsg().

Примем сообщение, которое содержит уведомления о разрешении записи:

// Буфер для вспомогательных данных.

std::array<char, CMSG_SPACE(sizeof(uint32_t))> ancil_data_buff;

// Буфер для обычных данных — предыдущая строка.

iovec iov[1] = { { buf.data(), buf.size() } };

msghdr msg =

{

.msg_name = nullptr, .msg_namelen = 0,

.msg_iov = iov, .msg_iovlen = 1,

.msg_control = &ancil_data_buff[0],

.msg_controllen = ancil_data_buff.size(),

.msg_flags = 0

};

// Принять сообщение.

if (-1 == recvmsg(sock_fd, &msg, MSG_ERRQUEUE))

throw std::system_error(errno, std::system_category(), "recvmsg");

// Обработать уведомления.

auto [data, info] = read_notification(&msg);

std::cout << "Data: " << data << ", Info: " << info << std::endl;

Обработаем уведомления:

std::pair<uint32_t, uint32_t> read_notification(struct msghdr *msg)

{

const cmsghdr *cm = CMSG_FIRSTHDR(msg);

if (cm->cmsg_level != SOL_IP && cm->cmsg_type != IP_RECVERR)

throw std::system_error(errno, std::generic_category(), "cmsg");

// Структура уведомления.

const sock_extended_err *serr =

reinterpret_cast<const sock_extended_err *> CMSG_DATA(cm);

// Необходимо проверить код ошибки и то, что уведомление пришло

// от ZeroCopy.

if (serr->ee_errno != 0 || serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)

throw std::system_error(errno, std::system_category(), "recvmsg");

return std::make_pair(serr->ee_data, serr->info);

}

Поля [ee_info, ee_data] содержат 32-битный диапазон в буфере, куда можно безопасно записывать данные.

Внимание! Уведомление может поступить до того, как данные будут полностью переданы. То есть уведомление о завершении работы с буфером не является уведомлением о завершении передачи.

Другая опция — TCP_ZEROCOPY_RECEIVE — не отправляет и не принимает управляющую информацию, но для удобства опишем работу с ней.

Эта опция используется только в getsockopt() и принимает указатель на структуру tcp_zerocopy_receive:

#include <linux/tcp.h>

struct tcp_zerocopy_receive

{

// Адрес мэппинга, созданного с помощью mmap().

__u64 address;

// Количество байтов данных, которые будут записаны в мэппинг.

// После чтения в буфер сюда ядро запишет количество прочитанных байтов.

__u32 length;

// Объем данных, который нужно вычитать, чтобы вернуться к режиму

// копирования. Устанавливается ядром.

__u32 recv_skip_hint;

// Количество байтов в очереди чтения. Устанавливается ядром.

__u32 inq;

// Код ошибки сокета, устанавливаемый ядром.

__s32 err;

// Адрес буфера для копирующего I/O.

__u64 copybuf_address;

// Размер буфера. Устанавливается пользователем. Изменяется ядром.

__s32 copybuf_len;

// Флаги, устанавливаемые пользователем.

__u32 flags;

};

При возврате из функции длина буфера устанавливается равной количеству байтов, фактически сопоставленных с мэппингом. Сопоставлены они до тех пор, пока диапазон не будет отменен с помощью munmap() или они не будут заменены другим вызовом getsockopt().

Проблема с данным механизмом в том, что он может работать не всегда и существует для того, чтобы уведомить ядро, что при обмене данными с приложением можно обойтись без копирования. Но ядро все еще может копировать данные, если адаптер не поддерживает разгрузку, выделенный буфер слишком маленький или неправильно выровнен, используется туннелирование и т.п.

API тоже выглядит несколько искусственным, поэтому, если требуется высокая производительность, как вариант, следует использовать XDP, DPDK или иную подобную технологию, описанную далее. Это более зрелые решения, которые предоставляют больше возможностей и позволяют полностью избежать копирования.

Kernel bypass

Если требуется сложная обработка и быстрая обработка данных в условиях высоких нагрузок, можно использовать подход kernel bypass — исключения стека ядра.

Реализуется данный подход несколькими разными технологиями.

In-Kernel FastPath

Это технология, которая позволяет пересылать пакеты без дополнительной обработки в ядре, что значительно повышает скорость и удобнее, чем ZeroCopy.

В Linux данная технология реализуется фреймворком XDP — eXpress Data Path, показанным на рис. 23.4.

Рис. 23.4. Схема работы XDP

Фреймворк может обрабатывать пакеты в двух режимах:

• Обработка данных в ядре программой, интерпретируемой eBPF. В таком случае трафик вообще не передается в пространство пользователя. Но программированием уровня ядра это не является, так как BPF выполняет скрипты безопасно.

• Передача в пространство пользователя через сокеты семейства AF_XDP.

О BPF очень кратко упомянуто в конце главы 13. В случае его использования данные перехватываются на очень раннем этапе обработки — еще в драйвере сетевого интерфейса, то есть сразу после обработки прерывания и до выделения памяти, выполняемого сетевым стеком. После чего обработку выполняет программа eBPF. Она может изменить данные пакета и вернуть код, определяющий следующие действия над пакетом:

• XDP_ABORTED — удалить пакет с исключением точки трассировки. По сути, то же, что XDP_DROP.

• XDP_PASS — отправить пакет в сетевой стек.

• XDP_DROP — отбросить пакет. Это действие выполняется по умолчанию, если код некорректен.

• XDP_TX — отправить пакет через тот же адаптер, с которого он был принят.

• XDP_REDIRECT — перенаправить пакет на другой сетевой адаптер или сокет AF_XDP.

XDP может отбросить порядка 26 миллионов пакетов в секунду на один процессор.

Обработка данных с использованием XDP невероятно гибкая. Например, разработчик может выполнить быструю проверку и препроцессинг принятого пакета в ядре без написания своего модуля, и если пакет его устраивает, отправить его приложению через сокет AF_XDP, при необходимости убрав лишние уровни протоколов.

Если же требуется более сложная обработка, например сборка IP-пакета из фрагментов, программа для eBPF может отправить пакет обратно в стек.

Из-за того что область DMA обычно доступна только для чтения, препроцессинг в ядре потребует копирования пакета.

Особенность сокетов AF_XDP в том, что помимо необработанных данных, которые вообще не попадают в сетевой стек, в них реализована возможность обмена данными между ядром и пространством пользователя без копирования, что дает возможность реализовывать очень высокопроизводительные сетевые приложения.

Чтобы XDP мог выполняться, от драйвера требуется возможность установки RX-хука. Такую возможность поддерживают как минимум драйверы Mellanox mlx4 и mlx5, Cavium — thunder и qede, Broadcom — bnxt, Intel ixgbe и драйвер virtio-net.

От сетевого адаптера требуется по крайней мере возможность обработки пакетов в несколько очередей.

Ко всему прочему в XDP реализовано выполнение программы eBPF на контроллере сетевого интерфейса. И хотя это требует поддержки от сетевого адаптера, эта функциональность работает во многих устройствах от Intel, Mellanox и Netronome.

Если выполнение на адаптере не поддерживается, XDP просто будет использовать общую реализацию, выполняя код eBPF на процессоре.

Сокет AF_XDP создается обычным вызовом socket(). С каждым таким сокетом связаны два кольца: кольцо RX, в которое сокет получает данные, и кольцо TX, с помощью которого отправляются пакеты. Эти кольца регистрируются и измеряются с помощью опций XDP_RX_RING и XDP_TX_RING.

Кольца содержат дескрипторы, которые указывают на буфер данных в области памяти, называемой UMEM. RX и TX могут совместно использовать один и тот же UMEM, поэтому пакет не требуется копировать между ними. Если же пакет нужно сохранить, например, для повторной передачи, достаточно изменить указывающий на него дескриптор, не копируя данные.

LibBPF

Для упрощения работы с XDP существует библиотека Libbpf. Она содержит вспомогательные функции в tools/lib/bpf/xsk.h для работы с AF_XDP-сокетами, позволяя выполнять следующие действия:

• настройку AF_XDP-сокета;

• безопасный и быстрый доступ к кольцам XDP в плоскости данных.

XDP в ОС Windows

XDP реализован не только в Linux. В 2022 году Microsoft выпустила бесплатную открытую реализацию XDP232 для Windows, поставляемую по лицензии MIT. Microsoft сотрудничает с другими компаниями и добавляет поддержку XDP в протокол MsQuic.

Фреймворки, реализующие kernel bypass

Еще более высокую производительность обеспечивают фреймворки, которые работают с оборудованием напрямую. Часто, когда говорят о kernel bypass, имеют в виду именно такой подход.

Таких фреймворков несколько:

• PACKET_MMAP233 — Linux API для быстрого захвата пакетов. Строго говоря, это не совсем kernel bypass, скорее исключение лишних копирований данных пакета.

• DPDK234 — фреймворк, изначально способный работать «на железе» без ОС. Реализует kernel bypass для ОС Linux, ОС FreeBSD и в будущем ОС Windows.

• PF_RING235 — фреймворк, который включает модули ядра, позволяющие выполнить kernel bypass и обработку пакетов в пользовательском пространстве.

• Netmap236 — хорошо развитый фреймворк для FreeBSD и Linux. Использует модули ядра для реализации kernel bypass и несколько патчей на драйверы. Работа выполняется через устройство /dev/netmap. API — обычные сокеты, предоставляемые LibC.

• Snabb237 — фреймворк на Lua. Реализовать его позволяет Linux User I/O. Развивается не очень активно.

• WSD238 — фреймворк в ОС Windows, который дает возможность использовать сокеты поверх RDMA, не включая большую часть стека ОС.

Очень кратко рассмотрим некоторые из названных фреймворков.

PACKET_MMAP

Для отправки или получения пакетов PACKET_MMAP предоставляет кольцевой буфер, отображаемый в пространство пользователя. Размер буфера настраиваемый.

Используется данный фреймворк для работы с сокетами семейства AF_PACKET, которые не выполняют обработку данных стеком ядра, и можно считать, что это минималистичный фреймворк для kernel bypass.

Чтобы читать пакеты, нужно просто их дождаться, а отправить несколько пакетов можно за один системный вызов. Это обеспечивает максимальную пропускную способность. Использование общего буфера между ядром и пользователем также позволяет минимизировать количество копий пакетов.

PACKET_MMAP можно использовать для повышения производительности процесса захвата данных на высоких скоростях относительно скорости процессора, если драйвер сетевой карты поддерживает NAPI или агрегацию прерываний. Кроме того, можно привязать ядра процессора к сетевым адаптерам для оптимизации.

DPDK

DPDK, или Data Plane Development Kit, — проект с открытым по BSD-лицензии исходным кодом, управляемый Linux Foundation. Изначально создавался компанией Intel для работы в режиме «прямо на железе». Передача данных в DPDK показана на рис. 23.5.

Рис. 23.5. Передача данных в DPDK

Фреймворк работает на x86, ARM и PowerPC, обеспечивает поддержку приложений Linux и FreeBSD. В настоящее время осуществляется его портирование на ОС Windows239.

В состав DPDK входят:

• Драйверы для опроса контроллера сетевого интерфейса. На данный момент они базируются на вводе-выводе в пользовательском пространстве240.

• Набор библиотек для обработки пакетов.

Драйверы контроллера сетевого интерфейса, предоставляемые DPDK, оптимизированы следующим образом:

• В диспетчере очередей реализованы неблокирующие очереди.

• Диспетчер буферов использует предварительно выделенные буферы фиксированного размера.

• Менеджер памяти выделяет пулы объектов в памяти и использует кольцевые буферы для хранения свободных объектов, что гарантирует равномерное распределение объектов по всем каналам DRAM.

• Драйверы режима опроса работают без асинхронных уведомлений, что снижает накладные расходы.

Интересной особенностью DPDK является наличие возможности производить вычисления, связанные с сетевыми данными, на графических адаптерах и тензорных процессорах NVIDIA, что многократно повышает его вычислительные мощности.

Для конкретных аппаратных и программных сред фреймворк предлагает библиотеки, которые предоставляют «уровень абстракции среды», или EAL — Environment Abstraction Layer.

Этот уровень предоставляет стандартный API для библиотек, доступных аппаратных ускорителей, различных элементов оборудования и операционной системы. Если для конкретной платформы существует EAL, разработчики могут просто связывать приложения с библиотекой для создания приложений.

EAL также предоставляет дополнительные сервисы, в том числе привязку ко времени, общий доступ к шине, функции трассировки и отладки, операции с сигналами. С помощью библиотек DPDK можно реализовать в пользовательском пространстве модель с низкими накладными расходами: конвейерную, управляемую событиями или гибридную.

PF_RING

PF_RING241 — модуль ядра Linux и фреймворк пользовательского пространства, который позволяет обрабатывать пакеты с высокой скоростью, обеспечивая при этом согласованный API для приложений.

На рис. 23.6 показано, как передаются данные в PF_RING.

Рис. 23.6. Передача данных в PF_RING

В отличие от DPDK, вместо ввода-вывода в пространстве пользователя используются модули ядра, позволяющие драйверу направлять пакет от сетевого адаптера напрямую к PF_RING. Режим работы PF_RING определяется при загрузке модуля через параметр transparent:

➭ sudo insmod pf_ring.ko transparent_mode = 0

Параметр может принимать следующие значения:

• 0 — пакеты отправляются в PF_RING через стандартные механизмы ядра.

• 1 — пакеты отправляются непосредственно драйвером адаптера в PF_RING. Они по-прежнему распространяются на другие компоненты ядра.

• 2 — чистый kernel bypass.

Если значение параметра равно нулю, оба пакета отправляются в PF_RING и остальным компонентам ядра. Все драйверы поддерживают этот режим. В обычном режиме производится опрос сетевых адаптеров с помощью Linux NAPI. В свою очередь, NAPI копирует пакеты с адаптера в кольцевой буфер PF_RING, а затем приложение пользовательского уровня считывает пакеты из кольца.

В этом сценарии есть два опроса: приложение и NAPI, что приводит к трате времени процессора на опрос. Но преимущество в том, что PF_RING может распределять входящие пакеты параллельно по нескольким кольцам, а значит, и приложениям.

В режиме 1 обработка ускоряется, так как пакеты копируются драйвером сетевого адаптера без прохождения обычного пути ядра. Этот вариант используют, только если драйвер сетевой карты поддерживает PF_RING.

В режиме 2 пакеты отправляются непосредственно драйвером адаптера в PF_RING. Они не передаются ядру и его компонентам, чтобы не замедлять их захват. Этот режим самый быстрый: после копирования в PF_RING пакеты отбрасываются сразу после их обработки.

На рис. 23.7 показана модульная архитектура PF_RING, где помимо основных модулей ядра видны дополнительные компоненты.

Рис. 23.7. Архитектура PF_RING

Дополнительные модули:

• ZC, или ZeroCopy242, — фреймворк обработки пакетов, который позволяет обрабатывать пакеты со скоростью линии 1–10 Гбит/с как на прием, так и на передачу при любом размере пакета. Реализует операции без копирования между процессами и виртуальными машинами KVM. По сути — преемник DNA/LibZero243, предлагающий единый и согласованный API. Инструмент платный, но существует бесплатная версия, которая обрабатывает ограниченное количество пакетов и работает только на адаптерах Intel.

• Модули карт на базе ПЛИС — добавляет поддержку многих вендоров: Accolade, Exablaze, Endace, Fiberblaze, Inveatech, Mellanox, Myricom/CSPI, Napatech, Netcope и др.

• Модуль стека — позволяет вводить пакеты в сетевой стек Linux.

• Модуль Timeline — позволяет беспрепятственно извлекать трафик из дампов Ntop n2disk244 с помощью PF_RING API.

• Модуль Sysdig — фиксирует системные события, используя подсистему sysdig.

Snabb

Данный фреймворк реализован на Lua и представляет собой концептуальный интерес. Snabb позволяет создавать высокопроизводительные конвейеры обработки данных, как, например, изображенный на рис. 23.8. Его основой являются каналы обмена данными и приложения или модули, посредством каналов соединяемые в сеть для обмена пакетами:

Рис. 23.8. Конвейер обработки данных

Каждое приложение получает пакеты из входных каналов, обрабатывает и передает через выходные каналы такой структуры:

// Структура, описывающая канал.

struct link

{

// Буфер канала, содержащий блок пакетов.

struct packet *packets[256];

// Позиции кольцевого курсора.

int read, write;

};

Пакет в Snabb — это просто буфер в памяти, как правило, содержащий Ethernet-кадры:

// Пакет данных, которыми обмениваются приложения.

struct packet

{

// Полезные данные.

unsigned char payload[10240];

// Длина буфера пакета.

uint16_t length;

};

Приложение может делать что угодно, главное, чтобы оно могло принимать и передавать пакеты.

В действительности пакеты не создаются и не уничтожаются при каждом обмене. Всегда имеется буфер из предварительно созданных пакетов. Буферы для пакетов берутся из набора свободных буферов и переиспользуются.

В Snabb реализовано уже порядка 30 подобных приложений. Среди них: интерфейсы ввода-вывода для сетевой карты или виртуальных машин, коммутатор Ethernet, маршрутизатор, брандмауэр, мост из сетевого стека Linux в сеть Snabb, работа с PCAP-форматом и прочие.

Когда требуются еще не реализованные функции обработки пакетов, разработчики Snabb пишут новые приложения. Большая часть кода приложений реализуется на Lua.

Не весь фреймворк реализован на Lua. Например, управление разделяемой памятью реализовано на C, некоторые функции в ядре также являются обертками над вызовами функций из LibC.

Приложения должны поддерживать следующий интерфейс:

{

input  = { ... },     -- Таблица именованных входных каналов.

output = { ... },     -- Таблица именованных выходных каналов.

pull   = <function>,  -- Функция для забора новых пакетов из системы.

-- Функция для дальнейшей отправки существующих пакетов.

push   = <function>

}

Сеть приложений выполняется внутри цикла обработки событий. На каждой итерации цикл получает блок пакетов от источников ввода-вывода, а затем направляет их по сети к конечным пунктам назначения.

Приложения компилируются LuaJIT, поэтому выполняются быстро. Параллельно как независимые процессы могут быть запущены несколько сетей приложений. Между ними можно передавать данные, используя приложения, выполняющие межпроцессный ввод-вывод.

Рассмотрим код приложения, сохраняющего пакеты в формате PCAP.

Сначала объявляется модуль, реализующий приложение, тип его видимости и модули, требуемые для его работы:

-- Вызов модуля. Функция Lua, которая создает загружаемый модуль из кода,

-- определенного в этом файле.

module(..., package.seeall)

-- Загрузка различных модулей.

local ffi = require("ffi")

-- Ядро Snabb: библиотека, описание канала, описание приложения.

local app  = require("core.app")

local lib  = require("core.lib")

local link = require("core.link")

-- Библиотека, реализующая формат PCAP.

local pcap = require("lib.pcap.pcap")

-- Пакетный фильтр.

local pf = require("pf")

Tap = {}

Следующий шаг — определение параметров конфигурации:

local tap_config_params = {

-- Имя файла, в который записываются пакеты.

filename = { required=true },

-- Режим: truncate для усечения файла после открытия,

-- append для его дополнения.

mode = { default = "truncate" },

-- Будут захвачены только пакеты, соответствующие фильтру.

filter = { },

-- Записывать только каждый N-й пакет, который прошел фильтр.

sample = { default=1 },

}

Теперь реализуются функции приложения, в том числе описанные ранее. Первая из них — конструктор «приложения»:

-- Конструктор будет выполняться при создании объекта приложения.

function Tap:new(conf)

-- Проверяет arg на соответствие спецификации в config.

-- Возвращает новую таблицу, содержащую параметры из arg,

-- и значения по умолчанию вместо пропущенных.

-- В случае появления неизвестных ключей генерирует ошибку.

local o = lib.parse(conf, tap_config_params)

-- Получить режим для open() по имени режима.

local mode = assert(({truncate='w+b', append='a+b'})[o.mode])

-- Открыть файл.

o.file = assert(io.open(o.filename, mode))

-- Если файл имеет нулевой размер, записать основной заголовок PCAP.

if o.file:seek() == 0 then pcap.write_file_header(o.file) end

-- Скомпилировать фильтр, если был задан.

if o.filter then o.filter = pf.compile_filter(o.filter) end

o.n = o.sample — 1

return setmetatable(o, {__index = Tap})

end

Вторая — обработчик, который выполняется, когда модулю переданы данные:

-- Будет выполняться каждый раз, когда кто-то передает модулю пакет.

function Tap:push ()

-- n должен храниться в объекте, чтобы не теряться между вызовами push().

local n = self.n

-- Цикл обхода каналов.

while not link.empty(self.input.input) do

-- Получить данные из канала (точнее — указатель на данные канала).

local p = link.receive(self.input.input)

-- Применение фильтра.

if not self.filter or self.filter(p.data, p.length) then

n = n + 1

-- Проверка номера пакета на соответствие записываемым.

if n == self.sample then

n = 0

-- Формирование заголовка PCAP и запись пакета

-- в открытый файл.

pcap.write_record(self.file, p.data, p.length)

end

end

-- Дальнейшая отправка пакета, если были подключены выходные каналы.

-- Если дальнейшая отправка не предусмотрена, пакет нужно освободить

-- через вызов packet.free(p).

link.transmit(self.output.output, p)

end

self.n = n

end

Приложение может содержать несколько модулей, один из которых является главным. Выше показан лишь простейший вариант.

Snabb-программа — упакованная сеть приложений. Она похожа на физический кластер сетевых устройств, соединенных между собой каналами Ethernet. Проектировать Snabb-программы можно по такому же принципу, как физические сети.

Модули, реализующие программы, содержат запускаемую фреймворком функцию run(), в которой могут обрабатывать параметры командной строки:

function run (parameters)

if not (#parameters == 2) then

print("Usage: example_replay <pcap-file> <interface>")

main.exit(1)

end

local pcap_file = parameters[1]

local interface = parameters[2]

Программа скрывает от пользователя структуру данной сети и компилируется в утилиту, которую можно запустить из командной строки. Таким образом пользователь может работать с «обычными утилитами», например tcpdump или netcat, и не знать, что они реализованы поверх Snabb.

Для реализации множества программ в одном исполняемом файле Snabb использует тот же прием, что и BusyBox: он ведет себя по-разному в зависимости от имени, по которому пользователь его вызывает.

При компиляции Snabb Switch вы получаете один исполняемый файл, поддерживающий все доступные программы.

Программу можно запустить, вызвав ее через snabb myprogram, или скопировать snabb в /usr/local/bin/myprogram и затем просто запустить myprogram.

Вместо копирования обычно делают символические ссылки.

Вот некоторые из уже реализованных Snabb-программ:

• VPWS — Virtual Private Wire Service, VPN уровня 2.

• Packetblaster — генератор пакетов для проведения нагрузочного тестирования сети. Позволяет создавать поток в несколько сотен гигабит в секунду, при этом почти не загружает процессор.

• Snabb NFV — сеть виртуальных машин поверх QEMU/KVM для обработки трафика на скорости порядка 100 Гбит/с.

• Firehose — адаптер для вызова из C-библиотеки функции для обработки трафика. Это дает возможность интегрировать пользовательский код на C в Snabb без знания Lua и процесса работы фреймворка. Модуль работает достаточно эффективно.

• LISPER — организует виртуальную Ethernet-сеть поверх сети IPv6.

Процесс работы программы типичен:

1. Получение данных из какого-либо источника, загружаемого динамически.

2. Обработка данных сетью из приложений, которая и является сутью программы.

3. Отправка данных в сеть и сохранение дампа в файл.

Взаимодействие с сетью может производиться посредством:

• raw-сокетов;

• интерфейсов TUN/TAP;

• драйверов сетевых адаптеров Mellanox, реализованных как приложения Snabb;

• драйверов сетевых адаптеров Intel 82599, также реализованных в Snabb.

Как ни странно, драйверы также реализуются на Lua и работают вне пространства ядра. Код их достаточно объемен, и здесь его приводить нет смысла. Он доступен в репозитории проекта Snabb.

Модель WSD

Windows Sockets Direct, или WSD, — интерфейс, реализованный в ОС Windows, который позволяет приложениям через WinSock использовать SAN, то есть сети с RDMA — удаленным прямым доступом к памяти.

Сети SAN работают поверх специального оборудования и таких физических сетей, как InfiniBand, Gigabit Ethernet, Fibre Channel и подобных, обеспечивающих немаршрутизируемый протокол с надежной и упорядоченной доставкой.

Оборудование передает данные из физической памяти одного узла на другой, не задействуя центральный процессор и промежуточную память, поэтому SAN имеют производительность в 2–2,5 раза выше по сравнению с обычными сетями. Это позволяет применять их в центрах обработки данных, например, для соединения серверов приложений и серверов баз данных.

В SAN все функции, обычно реализованные в TCP/IP, выполняет оборудование, поэтому стек протоколов ОС не используется.

WSD реализует SDP — Sockets Direct Protocol245, который является альтернативой TCP.

SAN реализуется через прокси-драйвер, позволяющий приложениям обходить компоненты ядра, что минимизирует количество системных вызовов.

Сравнение модели WSD и традиционной сокетной модели показано на рис. 23.9.

Рис. 23.9. Традиционная и WSD-модели

Доступ к SAN WSD обеспечивается с помощью программного коммутатора, направляющего сетевую активность SAN провайдеру WinSock SAN. Поэтому WSD позволяет любому приложению на базе WinSock, использующему стек TCP/IP, работать с SAN и RDMA без модификации.

Необходимо создать новый сокет, передав WSASocket() структуру WSASTARTUPINFO, заполненную провайдером WSD. DLL провайдера SAN экспортирует одну функцию инициализации — WSPStartupEx(), которая заполняет адресами функций таблицу диспетчеризации провайдера.

Для эффективной работы с такими «сокетами» используется асинхронный WinSock API и специальные функции, например WSPRdmaRead() и WSPRdmaWrite(), вместо обычных функций получения и отправки данных.

Существует расширенный набор функций, специфичных для SAN, которые можно получить через вызов функции WSAIoctl() с параметром SIO_GET_EXTENSION_FUNCTION_POINTER. Она вызовет функцию WSPIoctl() провайдера SAN.

Эти функции позволяют использовать WSD более эффективно. Например, функция WSPRegisterMemory() регистрирует массив буферов для передачи данных сокетом, а функции WSPRdmaRead() и WSPRdmaWrite() служат для прямой передачи данных через буфер RDMA.

Расширенные функции объявлены в Ws2san.h и описаны в разделе «Windows Sockets Direct Reference»246.

SAN предоставляет драйвер мини-порта NDIS для взаимодействия стека TCP/IP с оборудованием SAN. А WSP отображает аппаратные регистры SAN в память пользовательского режима, что дает возможность управлять оборудованием, минуя компоненты режима ядра. Именно это позволяет без изменения переносить приложения, использующие стек TCP/IP.

Программно-аппаратные решения

Если существует потребность в еще более высокой производительности и скорости обработки потока, следует применять специализированные решения, которые выполняют обработку независимо от CPU. Как правило, такие решения имеют специально разработанную аппаратуру и относятся к устройствам DPU — Data Processing Unit. Они либо содержат массив высокопроизводительных процессоров, либо частично реализованы на ПЛИС. Решения имеют доступ к достаточно большому объему высокоскоростной памяти, то есть представляют собой полноценный специализированный компьютер. Реализован он, как правило, в форм-факторе платы, подключаемой через высокоскоростные интерфейсы, например PCI Express.

Для таких устройств пишутся свои драйверы и нередко набор приложений для управления, получения статистики и т.д.

DPU, используемые для обработки сетевого трафика, называются SmartNIC — «умные» сетевые карты. По сути, они представляют собой программируемый ускоритель, на который может быть записано программное обеспечение, специфичное для выполняемой задачи.

Такие адаптеры выполняют следующие функции:

• Обеспечивают безопасность с помощью встроенного шифрования.

• Реализуют популярные протоколы безопасности, такие как TLS, IPSec, MAC Security.

• На аппаратном уровне выполняют протоколы доступа к данным, такие как RoCE, GPUDirect Storage, NVM Express и TCP.

• Поддерживают ЦОД с виртуализацией ввода-вывода, виртуальной коммутацией и маршрутизацией.

• Ускоряют сетевые стеки, в том числе программно-определяемые на пользовательском уровне.

Ведущие компании в данной области — Napatech и Myricom, но свои решения уже предлагают Nvidia, Xilinx, Intel, а также активно создаются новые стартапы, например линейка NVIDIA ConnectX, ориентированная на сети центров обработки данных. Решения этой линейки должны обеспечивать RDMA — удаленный прямой доступ к памяти.

Наиболее мощный адаптер ConnectX-7 имеет пропускную способность 400 Гбит/с. Адаптер может работать с Ethernet и Infiniband. Он поддерживает выполнение IPSec и TLS с аппаратным ускорением используемых в них функций, аппаратное ускорение хеширования SHA-2, распаковки и сжатия по алгоритму deflate и регулярных выражений. Ориентировочная цена такой «сетевой карты» порядка $1500.

Язык P4

Зачастую программирование описанных выше устройств выполняется на языке P4 — Programming Protocol-Independent Packet Processors. Это предметно-ориентированный язык для независимого от протокола описания процесса обработки пакета в плоскости данных. Принцип программирования на нем показан на рис. 23.9. При этом программа на P4 работает в плоскости управления, но может управлять объектами только в плоскости данных, то есть язык дает возможность управлять таблицами маршрутизации, регистрами устройства, обрабатывать пакеты и т.п. Операции производятся над такими объектами, как заголовки, парсеры заголовков, описывающие корректные данные, таблицы, метаданные.

Производители аппаратуры предоставляют SDK, в которую входит среда, включающая описание архитектуры и компилятор P4 для устройства.

Пользователь реализует программу на P4 для конкретной архитектуры. Компилятор генерирует конфигурацию плоскости данных, которая описывает логику пересылки и API для управления состоянием объектов плоскости данных из плоскости управления.

Рис. 23.10. Программирование на языке P4

Синтаксис языка C-подобный. Например, описание заголовка на P4 выглядит следующим образом:

header Ethernet_h {

bit<48> dstAddr;

bit<48> srcAddr;

bit<16> etherType;

}

Парсер может выглядеть так:

parser top(packet_in b, out Parsed_packet p) {

state start {

// Выделить Ethernet-заголовок.

b.extract(p.ethernet);

transition select(p.ethernet.etherType) {

// EtherType 800 ->

// новое состояние конечного автомата — parse_ipv4.

16w0x0800 : parse_ipv4;

// Перейти к разбору IPv6.

16w0x86DD : parse_ipv6;

}

}

// Разобрать IPv4-пакет.

state parse_ipv4 {

b.extract(p.ip.ipv4);

transition accept;

}

// Разбор IPv6-заголовка.

state parse_ipv6 {

b.extract(p.ip.ipv6);

// Принять пакет.

transition accept;

}

}

Описание адаптеров и языка P4 приведено как пример направления, в котором могут развиваться сетевые технологии. Сейчас это скорее «передний край», и большинству разработчиков он не понадобится, а погружение в тему потребует серьезного изучения материала. Но для интересующихся читателей спецификация языка и возможность для взаимодействия с его комьюнити доступны на сайте p4.org.

Резюме

Стек операционных систем постоянно развивается и оптимизируется, например, в него включаются такие механизмы, как Zero Copy в Linux, позволяющие обойти копирование данных между ядром и пользовательским пространством. Однако он все равно остается недостаточно быстрым, чтобы обрабатывать потоки данных, которые циркулируют в современных высокопроизводительных сетях. Для этого используются оптимизированные стеки и прямая работа с адаптерами в обход ядра ОС, то есть механизм kernel bypass.

Существует несколько вариантов реализации подхода kernel bypass: TUN/TAP-интерфейсы, In-Kernel FastPath в Linux, специальные фреймворки.

TUN/TAP частично полагается на стек ядра и нужен для реализации виртуальных сетевых интерфейсов, которые управляются из пользовательского пространства. Различные программы, такие как OpenVPN, l2tpns и OpenSSH, используют данный интерфейс для туннелирования.

TUN является интерфейсом сетевого уровня, который работает с IP-пакетами. TAP — интерфейс канального уровня, который обрабатывает Ethernet-кадры.

In-Kernel FastPath — это подход, который предполагает максимальное исключение стека ядра для увеличения производительности. В Linux он реализуется через фреймворк XDP, предполагающий обработку данных в ядре программой, интерпретируемой с помощью eBPF.

Трафик передается в ядро напрямую, без копирования из пространства пользователя. Пересылка пакета в пользовательское пространство при необходимости осуществляется через сокеты семейства AF_XDP.

Для разных платформ существуют разные фреймворки, например PACKET_MMAP в Linux, кросс-платформенные Netlink, DPDK и PF_RING.

PACKET_MMAP — нечто среднее между полноценными фреймворками и механизмом zero-copy, остальные же фреймворки предоставляют достаточно обширный набор библиотек и драйверов.

Фреймворк DPDK представляет собой набор библиотек в пользовательском пространстве и оптимизированные драйверы для опроса контроллера сетевого интерфейса. Особенностью DPDK является работа с пулами объектов в памяти и использование кольцевых буферов для хранения свободных объектов, что гарантирует равномерное распределение данных по всем каналам DRAM.

PF_RING — модуль ядра Linux и фреймворк пользовательского пространства, который позволяет обрабатывать пакеты с высокой скоростью, обеспечивая при этом согласованный API для приложений. PF_RING может распределять входящие пакеты параллельно по нескольким кольцам, а значит и приложениям, тем самым ускоряя обработку данных.

Интересным развитием концепции является фреймворк Snabb, реализованный на Lua, который позволяет создавать высокопроизводительные конвейеры. Он производит обработку данных в пользовательском пространстве. Даже некоторые драйверы физических адаптеров реализованы в пространстве пользователя.

В ОС Windows реализацией данного подхода можно считать WSD, позволяющую работать с RDMA через интерфейс WinSock2, но без обработки TCP/IP-стеком.

Когда требуется еще более высокая производительность, например, в узкоспециализированных сетевых устройствах, таких как SmartNIC, применяются специализированные решения, которые выполняют обработку вообще независимо от CPU. Такие решения либо содержат массив высокопроизводительных процессоров, либо частично реализованы на ПЛИС и часто имеют доступ к большому объему высокоскоростной памяти. Их программирование тоже постепенно стандартизируется. Сейчас для этого используется предметно-ориентированный язык P4.

Вопросы и задания

1. Для чего нужны интерфейсы TUN и TAP?

2. В чем различие между TUN- и TAP-интерфейсами?

3. С помощью каких ioctl-вызовов создаются TUN- и TAP-интерфейсы?

4. Поддерживаются ли TUN/TAP-интерфейсы только в Linux или в других ОС тоже? Если да, то в каких?

5. Какие библиотеки существуют для работы с TUN/TAP? Какими преимуществами они обладают по сравнению с системным интерфейсом?

6. Как воспользоваться TUN/TAP в Python? Какие пакеты, предлагающие подобную функциональность, существуют?

7. За счет чего механизм Zero-copy в Linux позволяет обойтись без копирования данных между пространством пользователя и пространством ядра?

8. Может ли процесс, использующий Zero-copy в Linux, перезаписать буфер, переданный вызову send() сразу после завершения вызова? Почему? Если не может, как процесс должен поступить?

9. Какие существуют варианты реализации подхода kernel bypass?

10. Что такое In-Kernel FastPath и как он может ускорить обработку пакетов в ядре Linux?

11. Какие фреймворки используются для обработки высокоскоростного сетевого трафика?

12. В каких случаях может быть предпочтительнее использовать API, отличный от стандартного POSIX, например XDP или DPDK?

13. Как работает фреймворк XDP в Linux?

14. Существует ли XDP в ОС Windows?

15. Для чего нужна библиотека LibBPF?

16. С каким семейством сокетов может работать фреймворк PACKET_MMAP?

17. За счет чего DPDK обеспечивает высокую пропускную способность и низкую задержку при обработке сетевых пакетов?

18. В чем основное отличие PF_RING от других высокоскоростных фреймворков?

19. Для чего нужен модуль ZeroCopy во фреймворке PF_RING?

20. Чем интересен фреймворк Snabb?

21. Каковы основные компоненты фреймворка Snabb?

22. Какие технологии можно использовать для ускорения обработки сетевого трафика на уровне ядра операционной системы?

23. Что такое Windows Sockets Direct?

24. Что представляет собой RDMA?

25. Для чего используются функции WSPRdmaRead() и WSPRdmaWrite() в SAN?

26. Какие возможности открывает WSP для управления оборудованием SAN из пользовательского режима?

27. Что требуется для переноса WinSock-приложений, использующих стек TCP/IP, для работы с SAN?

28. Что такое SmartNIC и чем его возможности отличаются от обычных сетевых карт?

29. Для чего требуется и где используется язык P4?

30. Из чего состоит P4-программа и как она загружается на устройство?

31. Дополните пример работы с TUN/TAP-интерфейсами заданием своего МАС-адреса на создаваемом интерфейсе и заданием нужного TUN/TAP-индекса интерфейса, например 10.

32. Напишите программу, которая будет фильтровать входящие пакеты на TUN-интерфейсе по IP-адресу и пропускать только пакеты с определенными адресами.

225 https://lwn.net/Articles/629155/

226 https://virt.kernelnewbies.org/MacVTap

227 https://github.com/LaKabane/libtuntap

228 https://github.com/HBSnail/UniversalTunTapDriver

229 https://github.com/msantos/tunctl

230 https://pkg.go.dev/github.com/jamescun/tuntap

231 https://pypi.org/project/python-pytuntap/

232 https://github.com/microsoft/xdp-for-windows

233 https://www.kernel.org/doc/Documentation/networking/packet_mmap.txt

234 https://www.dpdk.org/

235 https://www.ntop.org/products/packet-capture/pf_ring/

236 https://github.com/luigirizzo/netmap

237 https://github.com/snabbco/snabb

238 https://learn.microsoft.com/en-us/windows-hardware/drivers/network/windows-sockets-direct

239 https://doc.dpdk.org/guides/windows_gsg/index.html

240 https://lwn.net/Articles/232575/

241 https://www.ntop.org/products/packet-capture/pf_ring/

242 https://www.ntop.org/products/packet-capture/pf_ring/pf_ring-zc-zero-copy/

243 https://www.ntop.org/pf_ring/say-hello-to-libzero/

244 https://www.ntop.org/products/traffic-recording-replay/n2disk/

245 https://en.wikipedia.org/wiki/Sockets_Direct_Protocol

246 https://learn.microsoft.com/en-us/previous-versions/windows/hardware/network/ff565857(v=vs.85)

Глава 24. Проблемы сетевых приложений, диагностика и отладка

Устаревший код часто отличается от предлагаемого на замену тем, что он действительно работает и масштабируется.

Бьерн Страуструп, «FAQ: What is legacy code», 2007

Введение

В этой главе мы поговорим о проблемах, возникающих при разработке сетевых приложений, и выясним, какие существуют заблуждения относительно работы сети. Это поможет избежать распространенных ошибок в программном обеспечении и создания неэффективных приложений.

Мы сосредоточимся на проблемах в канале. И по причине того, что ключевым этапом в обнаружении ошибок является локализация их источника, узнаем, как искать точки возникновения проблем, а также как выяснить, где именно проблема — в канале или в приложении, — и научимся использовать инструменты для анализа сети. Для этого мы освоим утилиты трассировки, которые также помогут устранять проблемы на разных уровнях.

После этого сосредоточимся на окружении приложения и попытаемся на практике использовать различные инструменты.

Чтобы имитировать проблемы в канале, реализуем в Linux элементарный перехватчик вызовов библиотечных функций. Обнаружить его мы попробуем, используя утилиту strace, и, запустив генератор пакетов уровня ядра, покажем, что проблема не в коде приложения.

В конце главы мы очень кратко затронем фреймворк Scapy, с помощью которого также можно проводить отладку канала. Обычно Scapy применяется для поиска проблем безопасности, потому более подробно будет рассмотрен в следующей книге.

Заблуждения относительно распределенных систем

Зачастую разработчики забывают или не знают про сложность, которую представляет сеть. Отсюда и появляются заблуждения, ведущие к ошибкам в приложениях. Наиболее распространенные из этих заблуждений были сформулированы Sun Microsystems:

• Сеть является надежной средой.

• Задержка передачи данных равна нулю.

• Пропускная способность бесконечна.

• Сеть защищена и безопасна.

• Топология сети не меняется.

• В сети есть только один администратор.

• Транспортные расходы равны нулю.

• Сеть однородна.

В одной из статей для Fog Creek Tech Talks приводилась интересная причина обрыва сети: акулы, которые перекусывают кабели. Кабель испускает электромагнитные волны, а также может вибрировать от взаимодействия полей. Акула замечает это и, считая кабель добычей, атакует его.

Это одна из причин того, что кабели, прокладываемые через океан, всегда имеют прочные, в том числе кевларовые, оболочки.

Еще один реальный случай — ошибка в прошивке целой партии маршрутизаторов. В результате маршрутизаторы портили данные, затем вычисляли правильные контрольные суммы и отправляли испорченные данные далее по маршруту.

Вывод: в случае глобальных сетей может произойти любое, даже самое маловероятное событие, потому что количество подключений, маршрутов, устройств и влияние разнообразных непредвиденных факторов очень велико.

Последствиями заблуждений являются не только ошибки, но и низкокачественные, неэффективные приложения, которые не обладают достаточным уровнем надежности по ряду следующих причин:

• Минимальная обработка сетевых ошибок. Во время ошибок сети плохо написанные приложения могут останавливаться или бесконечно ждать ответного пакета. А когда сеть снова становится доступной, приложения не могут возобновить работу без перезапуска.

• Медленный ответ на запросы пользователя. Зачастую, к тому же, ответ содержит ошибки, хотя если бы разработчики учли задержки передачи, этого можно было бы избежать.

• Потребление неограниченного трафика. Это увеличивает количество ошибок и отброшенных пакетов в приложениях, что свидетельствует о неэффективном расходе пропускной способности канала. Из-за этого возможно появление узких мест, когда один участок, через который проходят данные, замедляет всю сеть, не позволяя использовать канал с максимальной эффективностью.

• Уязвимость для сетевых атак, при том что иногда атакующий остается незамеченным.

• Разработка без учета изменений характеристик сети, в том числе изменения топологии. И соответственно, приложения под эти изменения не адаптируются. Об изменениях топологии топологии, вызванных отказами, мы поговорим в книге 2, в главе о CAP-теореме.

• Разработка без учета того, что разные администраторы могут устанавливать конфликтующие политики. Это приводит к невозможности использования маршрутов и неработоспособности приложения. Хотя может существовать возможность использовать другой маршрут.

• Неинформированность о нижележащем протоколе. OSI обеспечивает абстракцию, но требуется помнить как минимум о том, что любой нижележащий протокол добавляет свои данные, например заголовки. Поэтому может возникнуть ситуация, когда данных, отправляемых приложением, в PDU меньше, чем служебных данных протоколов. Либо, наоборот, данных так много, что это приводит к фрагментации при отправке через сеть.

• Приложения не учитывают, что их данные чаще всего проходят через сети разных типов. Разные типы сетей означают разные характеристики: сравните магистральный канал T1 и домашнюю сеть Wi-Fi у пользователя. Кроме того, данные приходят через разные узлы: от смартфона под управлением Android до сервера под управлением Windows. Это приводит к тем же проблемам, что и первые три заблуждения.

Транспортные расходы TCP

Имеется «разогнавшееся» TCP-соединение, через которое на полной скорости осуществляется обмен данными. Вопрос: как часто, в пропорции к пакетам данных, принимающая сторона посылает ACK-пакеты?

Казалось бы, нечасто, с учетом больших окон, задержек ACK и т.п. На самом же деле в среднем подтверждается каждый второй пакет.

Причина в том, что основное событие, которое заставляет передатчик послать следующий пакет, — это не срабатывание таймера, а получение подтверждения, которое открывает окно для следующих пакетов. Поэтому подтверждений TCP отправляет много, являясь, по сути, протоколом ACK-paced (управляемым ACK).

Одно из практических следствий этого факта: если Wi-Fi обеспечивает заявленную скорость в 54 Мбит, то фактическая скорость, достижимая для TCP между станцией и точкой доступа, составляет приблизительно 22 Мбит, а между станциями — еще меньше.

Wi-Fi использует довольно большие зазоры между пакетами плюс плотный поток TCP ACK, который с учетом этих зазоров будет занимать существенную часть полосы. В результате пропускная способность для передачи полезных данных составит чуть меньше половины от исходной.

Точки возникновения проблем

Сеть состоит из узлов и связей между ними, поэтому проблемы могут возникать в следующих местах:

• непосредственно в узлах, образующих сеть;

• связях и каналах, их обеспечивающих.

Узлы сети либо участвуют в обмене непосредственно, например, как сервер, поставляющий ресурсы, и клиент, их потребляющий, или служат транзитными узлами, как маршрутизаторы. В норме будем считать, что транзитные узлы являются частью канала связи. То есть канал связи является частью канала передачи данных и образуется:

• транзитными узлами, большинство из которых составляют маршрутизаторы;

• средой передачи.

Возможные неполадки в каналах:

• Обрывы, шумы в среде передачи: проводах, оптических волокнах, помехи в эфире и т.п.

• Некорректная конфигурация маршрутизаторов.

• Перегрузка канала связи.

Возможные неполадки в узлах:

• Некорректная конфигурация сети.

• Ошибки в реализации прикладного сетевого ПО.

• Проблемы с аппаратурой, например физические неисправности сетевого интерфейса.

Неполадки в узлах, не являющихся транзитными, обычно приводят к отказу в обслуживании одного либо нескольких клиентов, но не разрушают целостность сети.

Возможны исключения, не имеющие прямого отношения к сетям. Например, узел отвечает за программную конфигурацию транзитных узлов сети или за управление их электропитанием. Конечно, в случае его сбоя выйдут из строя другие узлы. Но с точки зрения сети эту ситуацию возможно свести к неполадкам в узлах, которые были отключены.

Также узел может начать вредоносную активность относительно сети, например замедлять ее работу, переполняя пакетами. Это скажется на всей сети, но явно не является проблемой канала или транзитного узла, если пакеты не приводят к их сбою, например, в результате ошибок в коде обработки. Подобное можно также свести к ошибкам конфигурации узла.

Неполадки в транзитных узлах и в среде передачи могут привести к разделению сети на изолированные сегменты, то есть к разрушению сети.

Это книга не по сетевому администрированию, и поэтому следующие темы мы не рассматриваем:

• неполадки в каналах;

• ошибки в конфигурации узлов.

Их настройку и работу обеспечивает сетевой администратор или обладающий теми же навыками специалист. В современных системах, где конфигурация сети выполняется автоматически, с использованием протоколов типа DHCP, технологий ZeroConf, UPnP и подобных, тоже может требоваться настройка, которую также выполняет сетевой администратор. Потому что даже в случае реализации серверов и других компонентов программистом ответственность за работу сети несет администратор.

С точки же зрения программиста нас интересуют в первую очередь такие вопросы:

• Как понять, что ошибка — в реализации приложения.

• Как локализовать и устранить данную ошибку.

Поиск точки возникновения проблем

Обычно первая задача — понять, что ошибка кроется в приложении. Затем следует либо отладка приложения, либо обращение к сетевому администратору. Выяснить наличие ошибки можно путем исключения.

Обычно легче проверить и затем исключить сеть до начала поиска ошибок в приложении. Чтобы выяснить, есть ли проблемы в канале, можно использовать некоторые простейшие меры для его исключения:

• Попробовать другие каналы. Например, если узел подключается в сеть по корпоративному Ethernet, попробуйте Wi-Fi или другую сеть. Это не всегда возможно, но если проблема повторяется, она, вероятно, не в канале.

• Попробовать выполнить диагностику канала, используя специализированные инструменты, рассмотренные далее, например тот же netstress.

• Запустить сервер локально и проверить связь через интерфейс локальной петли. Если проблема там, это точно не проблема канала.

• Эмулировать стенд на виртуальных машинах в рамках одной физической машины и проверить работоспособность на этом стенде.

Но обычно достаточно посмотреть, что передается и что приходит. Для этого можно использовать генератор сообщений: простой клиент, отправляющий серверу последовательности ASCII-символов.

Существует множество таких решений, например Packet Sender247, Ostinato248, NPing249 и подобные. Встроенный генератор пакетов также имеет рассмотренный в предыдущей главе фреймворк DPDK.

Некоторые системные утилиты

В составе операционных систем, как правило, уже имеются некоторые средства диагностики, которые мы рассмотрим далее.

Среди прочих отдельно выделим:

• Обычный ping — самый доступный инструмент тестирования.

• Клиент-серверные утилиты тестирования:

• Iperf и Iperf3250 — утилиты, позволяющие тестировать канал по TCP и UDP, создавая как однонаправленный поток от клиента до сервера, так и двунаправленный.

• NetStress251 — его часто используют для тестирования пропускной способности маршрутизаторов.

Ping

Плюс обычного ping в том, что на большинстве систем он уже присутствует и его не требуется устанавливать.

Одновременно плюсом и минусом является работа по ICMP:

• Большинство межсетевых экранов пропускают сообщения ICMP Ping.

• Он позволяет работать, не запуская отдельный сервер.

Но проверять он позволяет только сетевой уровень, и нагрузочное тестирование сети, используя ping, выполнить затруднительно, хотя и реально. А проверку особенностей настройки TCP — вообще невозможно.

Типичная утилита ping позволяет252:

• Задать шаблон отправляемых данных через опцию -p. Например, -p ff для отправки только единиц. Шаблон в данном случае описывает байт шестнадцатеричным числом. То есть появляется возможность увидеть искажения шаблона на принимающей стороне. Опцию поддерживают не все реализации утилиты.

• Задать размер пакета через опцию -s, чтобы определить, какой предельный размер можно передать через канал.

• Управлять фрагментацией IP-дейтаграмм, используя опцию -M.

• Задавать исходящим пакетам тег через опцию -m, чтобы, например, использовать разные политики маршрутизации.

• Включать запись отладочной информации нижележащих уровней, используя опцию -d. Это полезно для отладки сетевого стека. Опция ставит на сокет флаг SO_DEBUG. Правда, в ядре Linux данный флаг в большинстве случаев не будет иметь никакого эффекта.

• Выполнять «адаптивный пинг» — адаптирует RTT так, чтобы в сети был только один запрос, что для быстрых сетей обеспечивает лавинную рассылку и позволяет выполнить нагрузочное тестирование ICMP-пакетами. Включается опцией -A.

• Выполнять лавинную рассылку также с целью нагрузочного тестирования. В Linux ping — опция -f.

Подробнее об этом см. в man 8 ping.

Кроме того, отдельные реализации поддерживают другие полезные возможности. Поэтому диагностику стоит начать с запуска ping.

Пример запуска ping с шаблоном данных:

➭ ping localhost -p a1f1

PATTERN: 0xa1f1

PING localhost(localhost.localdomain (::1)) 56 data bytes

64 bytes from localhost.localdomain (::1): icmp_seq=1 ttl=64 time=0.051 ms

64 bytes from localhost.localdomain (::1): icmp_seq=2 ttl=64 time=0.065 ms

64 bytes from localhost.localdomain (::1): icmp_seq=3 ttl=64 time=0.025 ms

64 bytes from localhost.localdomain (::1): icmp_seq=4 ttl=64 time=0.079 ms

^C

--- localhost ping statistics ---

4 packets transmitted, 4 received, 0% packet loss, time 3032ms

rtt min/avg/max/mdev = 0.025/0.055/0.079/0.019 ms

В последней строке указано время с момента отправки запроса до получения ответа. В конце показано количество отправленных пакетов и пришедших ответов: если числа равны, значит, потерь нет.

В окне Wireshark на рис. 24.1 видно, что была отправлена дейтаграмма, причем данные без искажений.

Рис. 24.1. Дамп сетевых пакетов, отправленных ping

Внимание! В данном случае по умолчанию используется IPv6, что видно по адресу ::1. Поэтому и протокол, который использует Ping, — ICMPv6, а не ICMP. При необходимости можно явно указать IPv4-адрес узла, в таком случае будут видны пакеты ICMPv4.

Размер данных — 40 байт, но в реальности приходит 56 байт. Еще 16 байт — это структура timeval, которая содержит временную метку, возвращаемую функцией gettimeofday().

Также можем использовать ping, реализованный ранее:

➭ sudo build/bin/b01-ch04-ping-from-root localhost

Pinging "localhost.localdomain" [127.0.0.1]

Raw socket was created...

Starting to send packets...

TTL = 255

Recv timeout seconds = 1

Recv timeout microseconds = 0

Sending packet 0 to "localhost" request with id = 35448

Sending packet 1 to "localhost" request with id = 35448

Receiving packet 0 from "localhost" response with id = 35448, time = 0.01ms

Sending packet 2 to "localhost" request with id = 35448

Внимание! Напомним, что хотя ping на Linux может работать через установку привилегии CAP_NET_RAW253 либо, на новых версиях, используя специальный вариант создания ICMP-сокета, обычно утилита использует raw-сокеты и требует прав root.

В дампе на рис. 24.2 видно, что приходит «зашитая» последовательность символов — повторение шаблона 0xa1f1. Та же последовательность, как правило, отправляется сетевым стеком в ответе:

Рис. 24.2. Ответ на ping

В «семействе» ping существует несколько утилит, которые выполняют сходные действия:

• Arping254 — для работы по протоколу ARP в рамках сегмента ЛВС.

• Fping255 — для массового пингования хостов по списку.

• TCPing256 — проверяет, доступен ли нужный порт TCP.

Сходные решения существуют и для ОС Windows как отдельные утилиты. Например, утилита PsPing257 от Sysinternals, которая может работать и с UDP, или PaPing258 от Google, а также наборы скриптов PowerShell.

Netstat и SS

Netstat выводит информацию о сетевой подсистеме. В Linux программа Netstat является устаревшей, и в качестве замены предлагается использовать утилиту SS, речь о которой пойдет ниже. Однако Netstat до сих пор можно встретить в множестве разных систем.

Тип выводимых данных задается ключами режимов:

• Режим по умолчанию — список открытых сокетов.

• Режим --route или -r — таблицы маршрутизации ядра.

• Режим --groups или -g — состав групп многоадресной рассылки.

• Режим --interfaces или -i — список интерфейсов.

• Режим --statistics или -s — статистика по каждому протоколу. Например, позволяет узнать, сколько было получено IP-пакетов с ошибками в IP-заголовке.

Часто Netstat удобно вызывать с параметрами -nlp4, где:

• -n — выводить IP-адреса вместо имен узлов. Это быстрее благодаря отсутствию обращения к DNS. К тому же часто удобнее видеть адреса.

• -l — выводить только сокеты, которые открыты на прослушивание. Обычно требуется не список всех исходящих соединений из системы, а только список открытых портов. Эта опция их выводит.

• -p — выводить PID и пути к процессам, открывшим сокет.

• -4 — выводить только сокеты протокола IPv4. Для IPv6 следует задать параметр -6, для Unix-сокетов — -x, а для остальных см. man 8 netstat.

Помимо этих опций часто могут использоваться следующие:

• -t — отображать TCP-сокеты.

• -u — отображать UDP-сокеты.

Пример вывода Netstat для IPv6:

➭ netstat -nlp6

(Not all processes could be identified, non-owned process info

will not be shown, you would have to be root to see it all.)

Active Internet connections (only servers)

Proto Recv-Q Send-Q Local Address           Foreign Address         State PID/Program name

tcp6       0      0 :::5355                 :::*                    LISTEN

tcp6       0      0 :::1716                 :::*                    LISTEN 9259/kdeconnectd

udp6       0      0 :::1716                 :::*

9259/kdeconnectd

udp6       0      0 :::5353                 :::*

udp6       0      0 :::5355                 :::*

udp6       0      0 :::40207                :::*

udp6       0      0 :::48827                :::*

3016335/app.asar --

raw6       0      0 :::58                   :::*

Утилита SS — Socket Statistics — является более современной заменой Netstat. В Linux она показывает информацию, похожую на ту, которую выводит Netstat, только в несколько другом виде и более подробно.

Ключи данных приложений во многом совпадают, но ss не показывает таблицы маршрутизации и прочее, она заменяет только режим Netstat по умолчанию. Эта утилита работает напрямую через подсистему Netlink, тогда как более старый Netstat работает через ProcFS.

Пример вывода ss для IPv6:

➭ ss -nlp6

Netid  State  Recv-Q  Send-Q  Local Address:Port    Peer Address:Port  Process

icmp6 UNCONN  0       0       *:58                  *:*

udp   UNCONN  0       0       [::]:5353             [::]:*

udp   UNCONN  0       0       [::]:5353             [::]:*

udp   UNCONN  0       0       [::]:5355             [::]:*

udp   UNCONN  0       0       [::]:41798            [::]:*

tcp   LISTEN  0       4096    [::1]:631             [::]:*

tcp   LISTEN  0       4096    [::]:5355             [::]:*

tcp   LISTEN  0       50      [::ffff:127.0.0.1]:29 *:* "          kde-plasma

В ОС Windows похожей функциональностью обладает утилита TCPView259 от SysInternals. Она также позволяет видеть создание и уничтожение новых сокетов «в режиме онлайн».

Trace

Также можно изучить канал с помощью Traceroute260. Вывод утилиты нагляден:

➭ traceroute -w 15 ya.ru

traceroute to ya.ru (87.250.250.242), 30 hops max, 60 byte packets

1  router (192.168.2.1)  0.685 ms  1.222 ms  1.581 ms

2  0-0-b2.ysl.severttk.ru (80.92.20.1)  2.728 ms  3.276 ms  3.642 ms

3  sever-ttk.transtelecom.net (217.150.42.18)  4.159 ms  4.494 ms  5.546 ms

4  * * *

5  yandexspb-gw.transtelecom.net (217.150.63.225)  15.390 ms  17.645 ms  18.660 ms

6  sas-32z3-ae1.yndx.net (87.250.239.183)  31.749 ms vla-32z1-ae3.yndx.net (93.158.160.151)  20.712 ms *

7  ya.ru (87.250.250.242)  15.357 ms *  15.486 ms

Утилита показывает маршрут пакетов и количество миллисекунд до каждого промежуточного узла, что позволяет оценить скорость прохождения различных участков маршрута и увидеть, на каком из узлов возникают задержки передачи. Параметр -w 15 говорит о том, что ответ на зондирующий пакет надо ждать 15 секунд.

В Windows подобной функциональностью обладают утилиты Tracert261 и Pathping262. Одна из них обычно поставляется с ОС, какая — зависит от версии Windows.

Данные утилиты можно использовать не только для определения маршрутов, но и для некоторых других задач, например поиска узлов, которые не в полной мере соответствуют протоколам. Так, если узел пересылает IP-пакеты с нулевым TTL, он будет появляться в списке два раза.

Названные утилиты также служат для поиска узлов, которые не отвечают корректно или достаточно быстро. Такие узлы обозначены звездочками: от них утилита не дождалась ICMP-ответа при использовании зондирующих пакетов выбранного протокола. Другой протокол может дать иной результат.

Как работает Traceroute

Traceroute отправляет последовательность зондирующих пакетов целевому узлу. Обычно в реализациях для Unix-подобных ОС это UDP-пакеты с портами из диапазона от 33 434 до 33 534, как показано на рис. 24.3, но могут быть запросы ICMP Echo, дейтаграммы ICMPv6 Echo, UDP Lite или пакеты TCP SYN. В утилите tracert из состава ОС Windows доступны только ICMP или ICMPv6.

Рис. 24.3. Работа traceroute

У первого зондирующего пакета TTL установлен в 1, так что первый же транзитный узел вернет ICMP-ответ с кодом 11 «Time-to-live exceeded in transit».

Второй пакет Traceroute отправляет с TTL, равным 2. Согласно протоколу, каждый узел должен уменьшать TTL на 1. Поэтому такой пакет отбрасывается уже вторым транзитным узлом. И так далее.

Время между запросом и ответом фиксируется и выводится.

Когда TTL пакета становится достаточным, он достигает целевого узла. В этом случае ICMP-ответ меняется. В случае UDP или TCP зондирующих пакетов — на 3 «Destination unreachable (Port unreachable)» или на 0 «Echo reply», если использовался запрос ICMP Echo.

Изменившийся ответ является условием для остановки работы Traceroute.

Стоит учитывать, что узлы, которые не дают ответ, вовсе не обязательно проблемные: они просто не возвращают ICMP-ответ, потому что так сконфигурированы, но вполне могут пересылать данные. Поэтому условие неполучения ответа по тайм-ауту необходимо также учитывать в алгоритме.

Работа с доменными именами

Утилита Dig — Domain Information Groper, которую мы уже использовали в главе 2, позволяет выполнить запрос к DNS-серверу:

➭ dig mail.com

; <<>> DiG 9.18.5 <<>> mail.com

;; global options: +cmd

;; Got answer:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 58796

;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:

; EDNS: version: 0, flags:; udp: 65494

;; QUESTION SECTION:

;mail.com.                      IN      A

;; ANSWER SECTION:

mail.com.               300     IN      A       82.165.229.87

;; Query time: 50 msec

;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)

;; WHEN: Sun Oct 02 15:47:35 MSK 2022

;; MSG SIZE  rcvd: 53

Dig является частью пакета DNS-сервера BIND263, который доступен и для ОС Windows. Поэтому dig можно использовать, предварительно установив пакет либо отдельно скачав только утилиту.

Утилита Whois делает запрос к базе регистратора домена и позволяет узнать массу информации:

➭ whois mail.com

Domain Name: MAIL.COM

Registry Domain ID: 3083119_DOMAIN_COM-VRSN

Registrar WHOIS Server: whois.world4you.com

Registrar URL: http://www.world4you.com

Updated Date: 2023-05-30T14:58:40Z

Creation Date: 1997-03-24T05:00:00Z

Registry Expiry Date: 2025-03-25T04:00:00Z

Registrar: World4You Internet Services GmbH

Registrar IANA ID: 1476

Registrar Abuse Contact Email:

Registrar Abuse Contact Phone:

Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited

Name Server: NS-GMX.UI-DNS.BIZ

Name Server: NS-GMX.UI-DNS.COM

Name Server: NS-GMX.UI-DNS.DE

Name Server: NS-GMX.UI-DNS.ORG

DNSSEC: signedDelegation

DNSSEC DS Data: 55057 8 2 AD94C93CADAB166458C96CECF151A9C937C835D1377FBB2219793458225BF8F0

URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/

>>> Last update of whois database: 2024-07-16T22:35:19Z <<<

В ОС Windows утилита Whois реализована Sysinternals264 и может быть скачана отдельно.

Существует также несколько простых утилит, например:

• dnsdomainname — выводит доменную часть FQDN-узла, на котором утилита была запущена.

• hostname — выводит имя узла.

• nslookup — утилита, похожая на dig. Используется для получения записей домена с DNS-серверов. Может работать как в интерактивном, так и в пакетном режиме. Она более старая, чем dig, но присутствует в большей части Unix-подобных ОС.

Вместе с ними в пакете hostname также могут содержаться утилиты nisdomainname, ypdomainname и т.п. Эти утилиты уже устарели.

Утилиты администратора

Эти утилиты пригодятся скорее администратору либо разработчику при настройке сети:

• arp — управляет ARP кэшем IPv4. По умолчанию выведет содержимое кэша.

• ifconfig — утилита для управления сетевыми интерфейсами. Позволяет включать и выключать сетевые интерфейсы, задавать им адреса, устанавливать параметры и т.д. В Linux является устаревшей, замена ей — утилита ip.

• route — утилита для управления IP-маршрутами.

• ifup, ifdown, ifquery — включить, выключить интерфейс или запросить его состояние соответственно. Устарели.

• ip — утилита, которая заменяет многие предыдущие устаревшие. О ней можно прочесть в man 8 ip. Вот несколько ее наиболее часто используемых режимов:

• ip addr — вывод списка интерфейсов, а также управление IP-адресами;

• ip link и ip stats — управление канальным уровнем и сетевыми интерфейсами, просмотр статистики интерфейсов;

• ip neighbour и ip ntable — управление ARP-кэшем;

• ip route — управление маршрутами;

• ip tuntap — управление TUN/TAP-устройствами.

• dhclient — клиент DHCP. Чаще всего запускается автоматически. Но иногда полезен для того, чтобы получить адреса и другие параметры.

• ethtool — утилита для управления драйверами сетевых интерфейсов.

• mii-tool — управляет Media Independent Interface.

В Windows для управления сетевыми интерфейсами применяются следующие утилиты:

• ipconfig — управляет сетевыми интерфейсами:

• /renew — обновить адрес;

• /release — освободить адрес, то есть отправить сообщение DHCPRELEASE серверу DHCP;

• /setclassid, /showclassid — изменить либо показать код класса DHCP для адаптера;

• /flushdns, /registerdns, /displaydns — управление DNS-кэшем и его отображение.

• net — предоставляет доступ к различным сетевым сервисам, от отправки сообщений до управления сетевыми принтерами.

• netsh — конфигурирует сетевые параметры. Именно это основная команда, аналог Linux-утилит ifconfig и ip. Может работать в режиме интерактивной оболочки и выполнять настройку как локального, так и удаленного узла.

При вызове без параметров ipconfig выводит данные о подключениях:

PS C:\> ipconfig

Windows IP Configuration

Ethernet adapter Ethernet:

Connection-specific DNS Suffix  . :

IPv4 Address. . . . . . . . . . . : 192.168.3.254

Subnet Mask . . . . . . . . . . . : 255.255.255.0

Default Gateway . . . . . . . . . : 192.168.3.3

Команда netsh, вызванная без параметров, запустит интерактивную оболочку; в противном случае выполнит заданную команду.

Так, например, можно отобразить список всех сокетных провайдеров:

PS C:\> netsh winsock show catalog

Winsock Catalog Provider Entry

------------------------------------------------------

Entry Type:                         Base Service Provider

Description:                        MSAFD Tcpip [TCP/IP]

Provider ID:                        {E70F1AA0-AB8B-11CF-8CA3-00805F48A192}

Provider Path:                      %SystemRoot%\system32\mswsock.dll

Catalog Entry ID:                   1001

Version:                            2

Address Family:                     2

Max Address Length:                 16

Min Address Length:                 16

Socket Type:                        1

Protocol:                           6

Service Flags:                      0x20066

Protocol Chain Length:              1

Winsock Catalog Provider Entry

------------------------------------------------------

Entry Type:                         Base Service Provider

Description:                        MSAFD Tcpip [UDP/IP]

Provider ID:                        {E70F1AA0-AB8B-11CF-8CA3-00805F48A192}

Provider Path:                      %SystemRoot%\system32\mswsock.dll

Catalog Entry ID:                   1002

Version:                            2

Address Family:                     2

...

Или добавить второй DNS-сервер к подключению:

PS C:\> netsh interface ip add dns "Подключение по локальной сети" 8.8.8.8

Все команды покажут справку, если передать им аргумент /?. А netsh в интерактивном режиме покажет справку по команде help.

Команды Netsh разделяются на группы:

• winsock — группа команд управления Window Sockets;

• interface — управление сетевыми интерфейсами;

• firewall — управление межсетевым экраном;

и т.п.

Полезной командой в некоторых группах является команда reset, сбрасывающая настройки; например, при выполнении netsh winsock reset catalog будет сброшен каталог WinSock, то есть набор провайдеров.

Также для настройки сети можно использовать Microsoft System Console, или MSC, оснастки. Например, оснастка dhcpmgmt.msc управляет DHCP-сервером, а оснастки domain.msc, dssite.msc, dsa.msc управляют настройками домена и Active Directory.

Вызвать оснастки можно, просто набрав их имя с расширением в консоли.

Межсетевой экран

Иногда требуется отключить межсетевой экран, чтобы проверить, не блокирует ли он подключения. Сейчас в популярных ОС межсетевой экран уже встроен, и в каждой ОС он свой. Средства управления им зачастую тоже свои. Например, в Linux за управление межсетевым экраном Netfilter отвечает утилита iptables, а во FreeBSD встроен межсетевой экран IPFW, он конфигурируется утилитой ipfw. Межсетевой экран Packet Filter в OpenBSD, который используется также в MacOS X и стал основой для межсетевого экрана Core Force в ОС Windows, управляется через pfctl.

Чтобы скрыть все эти особенности, а также для упрощения работы с правилами часто используют более высокоуровневые утилиты, работающие в разных операционных системах и поддерживающие несколько разных межсетевых экранов. Например, существует инфраструктура NFTables, простые консольные утилиты, такие как UFW, Shorewall, Arno's Iptables Firewall, и множество графических интерфейсов, например firewall-config и Firewalld — демон и GUI, которые позволяют настраивать различные зоны для межсетевого экрана в RedHat, Firewall Builder, Gufw и т.д.

В Windows управление межсетевым экраном осуществляется через графический интерфейс, а также команду firewall утилиты netsh.

Перед тем как настраивать или отключать межсетевой экран через низкоуровневые команды, лучше выяснить, что используется для управления им в данной системе, и изучить документацию.

Конечно, возможности межсетевого экрана значительно шире. Например, он может перенаправлять различные подключения, искажать данные, перенаправлять данные и т.п. Здесь мы эти возможности не рассматриваем. Но их знание может пригодиться, чтобы хорошо владеть конфигурацией межсетевого экрана, что может быть полезно для разработчика.

Снифферы

Снифферы для разработчика сетевых приложений — как осциллограф для инженера, разрабатывающего электронные устройства. Они позволяют увидеть, что происходит в сети на уровне различных протоколов. Мы рассматривали их в главе 22.

Также для отладки полезны отладочные прокси-серверы, которые были рассмотрены в главе 21.

Прочие инструменты

Если простая диагностика через ping не дала результата, можно обратиться к более сложным инструментам:

• Packet Generator265 — модуль ядра для генерации пакетов в Linux, начиная с версии ядра 2.4. Позволяет исключить эффекты, наведенные пользовательским окружением. Пример эффекта мы рассмотрим, когда будем говорить о перехватчике.

• HPing3266 — мощная утилита для сборки пакетов. Заменяет несколько отдельных утилит267. Позволяет проверять предположения относительно функционирования сети. Например, указывать администратору на то, что межсетевой экран отбрасывает пакеты определенной структуры.

• Scapy268 — фреймворк на Python, который используется для сборки произвольных сетевых пакетов.

Большинство этих утилит требуют от пользователя определенного понимания сетей и протоколов.

Если даже названные выше средства не помогли локализовать проблему, стоит обратиться к сетевому администратору. Он может использовать аппаратные средства для измерения параметров канала на физическом и канальном уровнях. К тому же у сетевых администраторов, как правило, есть доступ к промежуточному оборудованию, что позволяет выполнить более полную диагностику сети.

Администраторы могут с уверенностью сказать, является источником проблемы канал или конкретный узел, а также проверить конкретное приложение на других узлах сети.

Трассировка

Некоторые утилиты позволяют отслеживать различные события, происходящие в ходе работы соединения. Они могут быть полезны не только при отладке, но и при оптимизации. Поэтому часть из них мы рассмотрим в книге 2, когда будем более подробно говорить об оптимизации.

Утилиты strace, ltrace и им подобные

Утилита strace269 используется для того, чтобы увидеть, с какими параметрами выполняются реальные системные вызовы. Она построена вокруг системного вызова ptrace270 либо аналогичного. Работает эта утилита и в BSD-системах. Трассировка вызовов — в целом широко используемая техника, независимо от конкретной ОС.

Еще одной полезной утилитой является ltrace271, которая позволяет трассировать вызовы библиотечных функций. В остальном данная утилита аналогична strace.

Во FreeBSD существует утилита truss, аналогичная strace.

В ОС Windows утилиты strace и ltrace может заменить WinDbg Logger272 из набора Debugging Tools for Windows, который входит в состав WDK или SDK.

Существует несколько других подобных утилит:

• Кросс-платформенная drltrace273.

• Process Hacker274 — графическая утилита под ОС Windows, которая показывает стек вызовов процесса.

• SpyStudio275 для ОС Windows. Показывает и интерпретирует вызовы, отображая результаты в структурированном и понятном виде.

• API Monitor276 для ОС Windows.

• DTrace. Трассировщик уровня ядра. Портированная версия для Linux277 и для ОС Windows278. Во FreeBSD, например, модуль идет в составе ядра. На Linux и Windows работает не всегда и требует установки драйвера.

• SystemTap279 для Linux. Более мощная альтернатива DTrace. В ОС Windows есть похожий механизм — Event Tracing for Windows280, который мы рассмотрим подробнее в разделе «Трассировка WinSock».

• Wsock-trace281 — трассировщик для WinSock.

Работу с strace мы рассмотрим далее в этой главе.

Проект BCC

В Linux многие трассировщики построены на основе eBPF, как, например, tcptracer-bpf, позволяющий отслеживать события открытия и закрытия сокета, установки соединения, изменения режима и прочие. В отличие от strace, эти утилиты работают полностью на уровне ядра, а не через один системный вызов.

Рис. 24.4. Утилиты BPF для сети

Одним из заметных проектов, объединяющих утилиты подобного рода, является BPF Compiler Collection или BPF toolkit282, часть которого показана на рис. 24.4. Это набор основанных на eBPF инструментов для создания эффективных программ трассировки ядра и управления им.

Проект, помимо сетевых функций, предлагает следующее:

• Получение статистики:

• блочных устройств, таких как диски: статистики производительности кэша, упреждающего чтения, задержки ввода-вывода по каждому процессу;

• файловых систем: отслеживание медленных запросов и операций, причины удаления файлов. Позволяет выполнять трассировку вызова open(). Содержит утилиту, предоставляющую для файлов функциональность, аналогичную утилите top;

• блокировок мьютексов и семафоров, измерение времени прерываний;

• кэша памяти ядра SLAB/SLUB: коэффициента попаданий и промахов кэша страниц, в том числе по каждому процессу. Показывает незавершенные выделения памяти. Может искать утечки памяти;

• виртуальных машин KVM;

• использования процессора;

• процессов. Дает возможность обнаруживать длительные задержки планирования процессов, считать кэш-промахи по процессам. Может внедрять ошибки для того, чтобы выяснить поведение системы, обнаруживать потенциальные взаимоблокировки.

• Отслеживание медленных запросов сервера MySQL и PostgreSQL с отображением задержек.

• Получение данных о программах, реализованных на языках высокого уровня:

• блок-схемы методов;

• отслеживание событий сборки мусора в разных языках;

• отслеживание событий создания потоков в Java.

• Общее профилирование:

• медленных вызовов ядра или пользовательских функций;

• возможностей безопасности;

• произвольных функций с помощью фильтров;

• вывода с устройства tty или pts.

• Построение гистограмм данных.

Набор инструментов очень полезен для выявления проблем в работающей системе. Например, если в приложении используется СУБД, время запроса может возрастать из-за медленного обращения к ней. BCC позволит легко измерить это время.

Приведем список утилит для исследования сетевого взаимодействия, находящихся в tools:

• gethostlatency — показать задержку для вызовов getaddrinfo() и gethostbyname().

• bindsnoop — трассировка системных вызовов bind() для IPv4 и IPv6.

• tcptracer — отслеживать установку и завершение TCP-соединений: connect(), accept(), close().

• tcpcong — отслеживать продолжительность состояния управления перегрузкой сокета TCP.

• tcptop — показать суммарную пропускную способность отправки/получения TCP по узлам. Утилита top для TCP.

• tcpsynbl — показать бэклог TCP SYN.

• tcpaccept — отслеживать пассивные соединения TCP, то есть вызовы accept().

• tcpconnect — отслеживать активные TCP-соединения, то есть connect().

• tcpconnlat — отслеживать задержку активного соединения TCP.

• tcpdrop — трассировать отброшенные ядром TCP-сегменты.

• tcplife — отслеживать сеансы TCP и получать суммарную статистику.

• tcpretrans — отслеживать повторные передачи TCP и TLP — Transaction Layer Packets, которые используются в PCI Express и служат, в том числе, для общения с адаптером.

• tcprtt — отслеживать время приема-передачи TCP.

• tcpstates — отслеживать изменения и продолжительность состояний TCP-сеанса.

• tcpsubnet — суммировать отправленные пакеты TCP по подсетям.

• sslsniff — прослушивать записанные и прочитанные данные OpenSSL.

• sofdsnoop — отслеживать файловые дескрипторы, прошедшие через Unix-сокеты.

• netqtop — отслеживать и отображать распределения пакетов в очередях сетевых карт.

Большинство инструментов реализуются на Python и на C, для них доступны хорошие примеры и документация, поэтому можно легко реализовать инструменты под свои задачи.

Утилита tcptrace

Утилита для анализа дампов PCAP. Выводит различную информацию о соединении:

• продолжительность соединения;

• отправленные и полученные байты и сегменты;

• количество повторных передач;

• RTT;

• изменения TCP-окон;

• данные о пропускной способности.

Она также может создавать графики для дальнейшего анализа. Начиная с версии 5, в дополнение к возможностям TCP реализована минимальная обработка UDP.

Используя tcptrace, удобно собирать данные с нескольких TCP-потоков, по которым приложение ведет обмен.

Утилита iptables-tracer

Утилита позволяет отслеживать распределение пакетов по цепочкам iptables. Она добавляет точки трассировки в конфигурацию.

С ее помощью можно отфильтровать необходимый трафик, используя фильтры, как в PCAP:

➭ iptables-tracer -f "-s 192.168.2.1 -p tcp --dport 443" -t 60s

14:42:00.284882 raw    PREROUTING   0x00000000 IP 192.168.2.1.36028 > 203.0.113.41.443: Flags [S], seq 3964591400, win 29200, length 0  [In:eth0 Out:]

14:42:00.287255 mangle PREROUTING   0x00008000 IP 192.168.2.1.36028 > 203.0.113.41.443: Flags [S], seq 3964591400, win 29200, length 0  [In:eth0 Out:]

14:42:00.288966 nat    PREROUTING   0x00008000 IP 192.168.2.1.36028 > 203.0.113.41.443: Flags [S], seq 3964591400, win 29200, length 0  [In:eth0 Out:]

Утилита nsntrace

Выполняет трассировку сетевой активности указанного приложения.

Создает виртуальные интерфейсы, запускает указанное приложение и записывает отфильтрованный через libpcap-трафик, например, в файл. Затем дамп можно обрабатывать любой стандартной утилитой, поддерживающей работу с PCAP.

Трассировка WinSock

Трассировка WinSock — это механизм для устранения неполадок, позволяющий отслеживать события на сокетах в ОС Windows. Он поддерживается, начиная с ОС Windows Vista.

События записываются в журнал. Существует два уровня трассировки:

• информационный;

• подробный.

На информационном уровне записываются следующие события:

• Создание сокета — при создании сокета, в том числе возвращенного функцией accept(), регистрируется событие, которое можно использовать для отслеживания времени жизни сокета.

• Привязка адреса — регистрируется связанный IP-адрес.

• Соединение — регистрируется IP-адрес удаленного абонента.

• Прерывания и отмены, вызванные WinSock, — событие регистрируется, когда WinSock прерывает или отменяет запрос.

• Сброс, инициированный провайдером транспорта, — событие регистрируется, когда провайдер транспорта указывает, что соединение было сброшено.

• Ошибки отправки и получения данных.

• Разрыв связи и закрытие дескриптора сокета.

На подробном уровне кроме них записываются:

• Отправка и получение буферов — регистрируются адрес и длина пользовательского буфера.

• Изменение опций сокета — событие возникает, когда приложение изменяет некоторые опции сокета, например SO_SNDBUF, SO_RCVBUF, SIO_ENABLE_CIRCULAR_QUEUEING, FIONBIO.

• Вызовы асинхронного ожидания — WSAPoll() и select().

• Маски событий — регистрируется маска события, переданная функции WSAEventSelect().

• Дейтаграммы — каждое поступление дейтаграммы, если в буфере нет места для ее копирования.

Запустить трассировку можно следующей командой:

C:\> logman start -ets mywinsocksession -o winsocklogfile.etl -p Microsoft-Windows-Winsock-AFD

The command completed successfully.

В файл winsocklogfile.etl будут записаны события провайдера, указанного через опцию -p. Параметр mywinsocksession — название сессии, которое в дальнейшем используется для управления трассировкой.

Узнать список провайдеров можно, используя команду logman query providers.

Провайдеров достаточно много. Для сети могут быть полезны Microsoft-Windows-TCPIP, провайдеры из семейства Microsoft-Windows-Network*, например Microsoft-Windows-NetworkStatus, и некоторые другие.

Остановить трассировку позволяет следующая команда:

C:\> logman stop -ets mywinsocksession

The command completed successfully.

Программа tracerpt.exe конвертирует бинарный протокол в текстовый:

C:\> tracerpt.exe winsocklogfile.etl -o winsockcatalogtracelog.xml

Input

----------------

File(s):

winsocklogfile.etl

100.00%

Output

----------------

DumpFile:        winsockcatalogtracelog.xml

Warning:

Some events do not match the schema.

Please rerun the command with -lr to get less restricted XML dump

The command completed successfully.

Будет сгенерирован XML-файл, который содержит все события:

<RenderingInfo Culture="ru-RU">

<Level>Information </Level>

<Opcode>Connected </Opcode>

<Keywords>

<Keyword>Datagram socket </Keyword>

<Keyword>Winsock initiated event </Keyword>

</Keywords>

<Task>AfdSendToWithAddress</Task>

<Message>

sendto: 0: Process 0xFFFFA309A99F2080, Endpoint 0xFFFFA309A91FE890, Buffer Count 1, Buffer 0xFFFFA309AD17CF68, Length 30, Addr 224.2.2.2:8995, Seq 3100, Status STATUS_SUCCESS

</Message>

<Channel>

Microsoft-Windows-Winsock Network Event/Operational

</Channel>

<Provider>Microsoft-Windows-Winsock Network Event </Provider>

</RenderingInfo>

Также для просмотра можно использовать Event Viewer, который поставляется вместе с ОС.

Подробнее о трассировке и параметрах событий см. в разделе Winsock Tracing283 MSDN.

Wsock-trace

Помимо встроенного механизма трассировки существует бесплатный трассировщик с открытым исходным кодом — Wsock-trace. Это DLL, которая встраивается между приложением и библиотекой WinSock, и чтобы воспользоваться трейсером, необходимо при компиляции приложения связать библиотеку с приложением.

Он позволяет выполнять трассировку с выводом на экран цветного текста, показывая в читаемом виде параметры функций и возвращаемые значения. Также отображается информация о вызывающем объекте, имя файла, номер строки, имя вызывающей функции.

Лучше всего трейсер работает с MSVC, поскольку код обхода стека требует присутствия PDB-файла с символами.

MinGW не создает PDB-символы, так как использует устаревшую библиотеку BFD.

Трейсер поддерживает функции расширений Microsoft, такие как AcceptEx() и ConnectEx().

Трейсер может учитывать особенности работы сети и, например, имитировать задержки обмена данными:

• Все строки трассировки начинаются с точной временной метки.

• Вызовы приема, передачи, select() и WSAPoll() могут быть замедлены на несколько миллисекунд. Это настраивается в конфигурационном файле.

• Отслеживается активность брандмауэра. Трейсер будет сообщать о действиях, вызывающих события WFP.

Благодаря интеграции с различными сервисами поддерживается множество дополнительных функций:

• Отображение информации о стране, городе и регионе, откуда пришел или куда ушел запрос, благодаря GeoIP.

• Отображение номера автономной системы и данных о ней.

• Поддержка черного списка на основе DNS.

Самая мощная возможность, которую предлагает данный трейсер, — поддержка сценариев Lua, которые могут изменять его поведение во время выполнения.

Диагностика сбоев вне приложения

Попробуем имитировать проблему, внешнюю по отношению к приложению, а затем обнаружить и решить ее. Для этого реализуем перехватчик вызовов, искажающий содержимое данных, отправляемых по сети приложением.

Минимальный перехватчик вызовов

Хотя для решения задачи подойдут разные языки, код перехватчика легче всего реализовать на языке достаточно низкого уровня, таком как C или C++. В данном случае у компилятора с такого языка должна быть возможность формировать таблицу импорта создаваемых библиотек, экспортируя функции по соглашению stdcall.

Приведем пример перехватчика на C++, работающего только в Linux, хотя подобный перехватчик легко реализуется и для Windows по тому же принципу:

// Включить поддержку всех стандартов в библиотеке:

// C89, C99, POSIX, BSD, SVID LFS, X/Open и расширения GNU.

// Макрос влияет на stdio и stdlib.

// Здесь нужен для доступности макроса RTLD_NEXT.

#if !defined(_GNU_SOURCE)

#    define _GNU_SOURCE

#endif

extern "C"

{

#include <dlfcn.h>

#include <unistd.h>

}

#include <cstdio>

#include <cstdlib>

#include <ctime>

// Объявляем, что функция init() должна быть вызвана при загрузке библиотеки

// автоматически.

static void init (void) __attribute__ ((constructor));

// Ссылки на функции в C-стиле.

typedef ssize_t (*write_t)(int fd, const void *buf, size_t count);

typedef int (*socket_t)(int domain, int type, int protocol);

typedef int (*close_t)(int fd);

static close_t old_close;

static socket_t old_socket;

static write_t old_write;

// Дескриптор сокета, пересылка данных через который будет перехвачена.

// В учебных целях будет перехвачено единственное открытие сокета.

static int socket_fd = -1;

static unsigned int seed = std::time(0);

Сохраним оригинальные адреса вызовов:

// В этой функции, которая будет вызвана при загрузке библиотеки,

// сохраняются адреса вызовов.

void init(void)

{

srand(time(nullptr));

// Использовать printf(), так как применять STL в C-коде нежелательно,

// как и смешивать две системы вывода.

printf("Interceptor library loaded.\n");

// Сохранить указатели на предыдущие вызовы.

old_close = reinterpret_cast<close_t>(dlsym(RTLD_NEXT, "close"));

old_write = reinterpret_cast<write_t>(dlsym(RTLD_NEXT, "write"));

old_socket = reinterpret_cast<socket_t>(dlsym(RTLD_NEXT, "socket"));

}

Теперь реализуем сами вызовы. Из динамической библиотеки они будут экспортированы по именам, поэтому, когда загрузчик приложения увидит в таблице импорта исполняемого файла, например, вызов функции write(), он вызовет первую функцию, которую встретит, то есть функцию из нашей библиотеки-перехватчика. Внутри этой функции мы выполним наши задачи, а затем передадим управление функциям из следующей библиотеки, то есть GLibC, адреса которых мы сохранили в init().

Как минимум необходимо перехватить несколько функций. И обязательно — функцию close():

// Перехват вызовов производится из C-библиотеки, поэтому необходимо выключить

// манглинг C++.

// Если манглинг не будет выключен, придется реализовывать перехватчик

// на чистом C.

extern "C"

{

int close(int fd)

{

if (fd == socket_fd)

{

printf("> close() on the socket was called!\n");

socket_fd = -1;

}

return old_close(fd);

}

Она сбросит ранее сохраненный дескриптор сокета и вызовет функцию close() из предыдущей библиотеки, и если это LibC, будет выполнен системный вызов.

Для отправки данных функцией send() в Linux используется функция write() для записи в дескриптор. Чтобы исказить данные, необходимо перехватить функцию write():

ssize_t write(int fd, const void *buf, size_t count)

{

auto char_buf = reinterpret_cast<const char*>(buf);

// Делать что-то, только если это запись в сохраненный дескриптор сокета.

if (char_buf && (count > 1) && (fd == socket_fd))

{

printf("> write() on the socket was called with a string!\n");

printf("New buffer = [");

for (size_t i = 0; i < count — 1; ++i)

{

// С некоторой вероятностью менять произвольные

// символы буфера на случайные, имитируя "мусор".

int r = rand_r(&seed);

char *c = const_cast<char *>(char_buf) + i;

// ASCII-символ.

if (1 == r % count) *c = r % (0x7f — 0x20) + 0x20;

putchar(*c);

}

printf("]\n");

}

return old_write(fd, buf, count);

}

Дескриптор socket_fd используется, чтобы проверить, что запись производится именно в сокет, а не, например, в дескриптор ввода или вывода.

После того как данные в буфере были искажены, будет вызван оригинальный write() из библиотеки.

Также перехватим функцию socket(), что нужно для переустановки дескриптора сокета, используемого в перегруженном write():

int socket(int domain, int type, int protocol)

{

int cur_socket_fd = old_socket(domain, type, protocol);

if (-1 == socket_fd)

{

printf("> socket() was called, fd = %d!\n", cur_socket_fd);

// Первый вызов функции socket(), необходимо сохранить дескриптор.

socket_fd = cur_socket_fd;

}

else

{

printf("> socket() was called, but socket was opened already...\n");

}

return cur_socket_fd;

}

} // Конец блока extern "C"

Конечно, перехватчик максимально упрощен и работает только с первым открытым сокетом, предполагая, что другие открыты не будут. Теперь проверим его работу с помощью Netcat.

Внимание! Некоторые версии Netcat используют функцию sendto(), которая может быть реализована не через вызов write(), а через системный вызов sendto(). В таком случае очевидно, что отправка данных не будет перехвачена вследствие того, что не будет сделан вызов write(). Если вы работаете с таким вариантом, перехватите sendto() по аналогии с write() либо используйте другой Netcat или даже написанный ранее UDP-клиент, реализованный через вызов функции send().

Загрузку библиотеки перехватчика выполним через переменную загрузчика LD_PRELOAD, которая предназначена для того, чтобы загружать различные ELF-файлы перед всеми остальными.

Подробнее о загрузчике и его переменных можно прочесть в man 8 ld.so или man 8 ld-linux.so.

Система загрузит нашу библиотеку первой и вызовет функцию init(). На принимающей стороне уже должен прослушивать экземпляр Netcat. Введем там строку Test string и увидим, что она была отправлена серверу:

➭ LD_PRELOAD=build/bin/libb01-ch24-call-intercepter.so nc localhost 11111

Interceptor library loaded.

> socket() was called, fd = 3!

Test string.

> write() on the socket was called with a string!

New buffer = [Test s#ring.]

Перехватчик исказил буфер, и получилась строка «Test s#ring.». Это и видно на принимающей стороне канала:

➭ nc -l -p 11111

Test s#ring.

Напомним, что в данном случае перехватчик нужен только для имитации сложной проблемы.

В реальной ситуации перехватчик, установленный в системе, не пишет сообщений и подключается автоматически, например используя файл /etc/ld.so.preload. Этот файл — аналог механизма AppInit_DLLs в ОС Windows — ветки реестра, содержащей пути к библиотекам, которые загрузчик будет загружать перед запуском любой программы.

Руткиты

Руткиты, установленные, чтобы скрыть действия перехватчиков, могут корректировать содержимое файла ld.so.preload «на лету», а также остаются невидимы для команд просмотра файловой системы. Кроме перехвата данных пользователя, они могут изменять передаваемые данные. Проявлять себя они могут лишь время от времени. Поэтому выявить перехватчик трудно и зачастую получится только на отключенной системе, потому что в работающей системе его просто может не быть на диске. Однако возможности отключить систему может не быть, так как сервер может работать под нагрузкой в непрерывном режиме.

Диагностика проблем сети в канале

Предположим, что вы отладили сервер и клиент приложения и проверили их работоспособность на других машинах. Вы уверены, что ваша программа работает. Однако на одном узле программа устойчиво работает с ошибкой. Можно ли списать все на канал и звать администратора сети? И да и нет.

Нет, потому что до канала есть несколько уровней абстракции, которые также могут работать некорректно.

Да, потому что, если не вы сами установили перехватчик, у вас проблемы. Если ваша машина взломана — это проблема безопасности компании, и необходимо привлекать службу безопасности для расследования инцидента.

Даже если проблема не в перехватчике, а за настройку машины отвечает администратор, эта проблема касается его. Но зачастую разработчик сам является администратором на своей машине и сам занимается настройкой сети. Поэтому до того, как все списать на канал и администратора, стоит попробовать разобраться и провести более глубокую отладку.

И только если проблема не найдена, следует обращаться к администратору, так как это означает, что скорее всего, причина все же в канале.

Обнаружение проблем с вызовами через strace

Если запустить приложение в GDB, поставить точку останова на write() и посмотреть стек вызовов, будет виден перехватчик:

Breakpoint 1, 0x00007ffff7eb18f0 in write () from /usr/lib/libc.so.6

(gdb) bt

#0  0x00007ffff7eb18f0 in write () from /usr/lib/libc.so.6

#1  0x00007ffff7fc1343 in write () from build/bin/libcall-intercepter.so

#2  0x000055555555846d in ?? ()

#3  0x000055555555694c in ?? ()

#4  0x00007ffff7de9b25 in __libc_start_main () from /usr/lib/libc.so.6

#5  0x0000555555556f6e in ?? ()

Но технически перехватчик может искажать стек вызовов.

К тому же не всегда есть возможность использовать отладчик, и не всякий графический интерфейс к отладчику покажет проблемы. Да и стек вызовов может быть настолько объемным, что вы не заметите лишней библиотеки.

Допустим, первоначальная отладка ничего не дала. Запустим программу, используя утилиту strace:

➭ LD_PRELOAD=build/bin/libb01-ch24-call-intercepter.so strace --trace="%net,write,read" -o log -- nc localhost 11111

Interceptor library loaded.

Interceptor library loaded.

> socket() was called, fd = 3!

Testing.

> write() on the socket was called with a string!

New buffer = [Testi^g.]

Tes buffer

> write() on the socket was called with a string!

New buffer = [Tes buf{er]

^C> close() on the socket was called!

В журнале увидим следующее:

# Создается новый сокет с дескриптором 3.

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3

# Перехватчик информирует об этом, записывая сообщение в stdout

# (дескриптор 1).

write(1, "> socket() was called, fd = 3!\n", 31) = 31

# Устанавливаются опции.

setsockopt(3, SOL_SOCKET, SO_LINGER, {l_onoff=1, l_linger=0}, 8) = 0

setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0

# Вызывается `connect`.

connect(3, {sa_family=AF_INET, sin_port=htons(11111),

sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS

# EINPROGRESS не ошибка.

getsockopt(3, SOL_SOCKET, SO_ERROR, [0], [4]) = 0

# Чтение строки из stdin (дескриптор 0).

read(0, "Testing.\n", 1024)             = 9

# Перехватчик выводит на консоль сообщения.

write(1, "> write() on the socket was call"..., 50) = 50

write(1, "New buffer = [Testi^g.]\n", 24) = 24

# Запись измененной строки в сокет (дескриптор 3).

write(3, "Testi^g.\n", 9)               = 9

read(0, "Tes buffer\n", 1024)           = 11

write(1, "> write() on the socket was call"..., 50) = 50

write(1, "New buffer = [Tes buf{er]\n", 26) = 26

write(3, "Tes buf{er\n", 11)            = 11

--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} --

# Закрытие обоих концов соединения.

shutdown(3, SHUT_RDWR)                  = 0

write(1, "> close() on the socket was call"..., 36) = 36

Видим, что реальный write() будет вызван с уже искаженной строкой. Значит, между вызовом функции из программы и системным вызовом есть перехватчик.

Некоторые опции strace

Опции --trace="%net,write,read" нужны для того, чтобы в лог записывались только вызовы, которые нас интересуют: сетевые, вызовы read и write.

Strace может выполнять множество других задач, например перехватывать вызовы и подменять возвращаемые ими значения, используя опцию -e inject=socket:error=<тип ошибки>.

Опция -k записывает в протокол стек вызовов.

Более подробно ознакомиться с этими возможностями можно в man 1 strace.

Утилиту strace удобно использовать как инструмент первичной диагностики, чтобы сразу видеть, какие системные вызовы и с какими параметрами были выполнены. Также ее можно дополнить утилитами, перечисленными в предыдущей главе.

Например, ltrace показывает библиотечные вызовы, и если использовать эти утилиты совместно, можно делать «срезы» активности приложения на разных уровнях, что дает возможность понять, как осуществляется обмен с внешней средой.

Для ОС Windows свободная утилита Wsock-trace позволяет увидеть вообще все сокетные вызовы сразу, что очень удобно.

Обнаружение проблем с помощью генератора пакетов уровня ядра

Перехватчик может подменять результаты вызова ptrace, искажать стек и делать еще многое для своей защиты, находясь в пространстве пользователя. Само приложение может также использовать антиотладочные приемы, например трассировать себя через вызов ptrace. Поскольку вызов не реентерабельный, трассировка через strace работать не будет.

Отладчики уровня пользователя на Linux тоже не будут работать: они используют ptrace. И остаются только отладчики уровня ядра284.

Но проверить канал можно не только с пользовательского уровня. Например, в Linux реализован генератор пакетов уровня ядра285. Обычно с его помощью измеряют производительность канала, минуя прикладной уровень. Но генератор также позволяет проверить, влияет ли прикладной уровень на канал.

Ядро должно быть собрано с опцией CONFIG_NET_PKTGEN, которая в большинстве дистрибутивов включена. Генератор собирается как модуль ядра и не загружается автоматически, поэтому сначала нужно загрузить модуль:

➭ sudo modprobe pktgen

После загрузки модуля в /proc/net появится каталог pktgen с файлами, управляющими генератором:

➭ ls /proc/net/pktgen

kpktgend_0  kpktgend_1  kpktgend_2  kpktgend_3  kpktgend_4  kpktgend_5  kpktgend_6  kpktgend_7  pgctrl

➭ sudo cat /proc/net/pktgen/pgctrl

Packet Generator for packet performance testing. Version: 2.75

Самих генераторов будет несколько: на каждое ядро процессора или на каждый поток ядра, так как потоки ядра привязаны к ядрам процессора.

Например, мы хотим использовать генератор 5:

➭ sudo cat /proc/net/pktgen/kpktgend_5

Running:

Stopped:

Result: NA

Настроим генератор на требуемый интерфейс:

➭ sudo bash -c 'echo "add_device lo" > /proc/net/pktgen/kpktgend_5'

Локальная петля была использована в учебных целях.

Как вы заметили, используется запись вида sudo bash -c 'echo ... > /dev/net/pktgen/...'.

Это сделано для того, чтобы можно было перенаправить вывод команды echo в файл с правами суперпользователя.

Вместо этого вы можете использовать консоль с правами суперпользователя, введя одну из следующих команд:

• sudo -i.

• su -.

либо просто выполнив логин на одном из псевдотерминалов, если вы не используете GUI.

Теперь можно проверить канал через данный интерфейс:

➭ sudo cat /proc/net/pktgen/kpktgend_5

Running:

Stopped: lo

Result: OK: add_device=lo

В каталоге pktgen появился новый файл:

➭ sudo cat /proc/net/pktgen/lo

Params: count 1000  min_pkt_size: 0  max_pkt_size: 0

frags: 0  delay: 0  clone_skb: 0  ifname: lo

flows: 0 flowlen: 0

queue_map_min: 0  queue_map_max: 0

dst_min:   dst_max:

src_min:   src_max:

src_mac: 00:00:00:00:00:00 dst_mac: 00:00:00:00:00:00

udp_src_min: 9  udp_src_max: 9  udp_dst_min: 9  udp_dst_max: 9

src_mac_count: 0  dst_mac_count: 0

Flags:

Current:

pkts-sofar: 0  errors: 0

started: 0us  stopped: 0us idle: 0us

seq_num: 0  cur_dst_mac_offset: 0  cur_src_mac_offset: 0

cur_saddr: 0.0.0.0  cur_daddr: 0.0.0.0

cur_udp_dst: 0  cur_udp_src: 0

cur_queue_map: 0

flows: 0

Result: Idle

Настраиваем генератор на интерфейсе. Задаем адреса, количество пакетов и прочие опциональные настройки:

➭ sudo bash -c "echo 'dst 127.0.0.1' > /proc/net/pktgen/lo"

➭ sudo bash -c "echo 'count 50' > /proc/net/pktgen/lo"

➭ sudo bash -c "echo 'delay 50' > /proc/net/pktgen/lo"

➭ sudo bash -c "echo 'pkt_size 1400' > /proc/net/pktgen/lo"

➭ sudo bash -c "echo 'udp_dst_min 11100' > /proc/net/pktgen/lo"

➭ sudo bash -c "echo 'udp_dst_max 11150' > /proc/net/pktgen/lo"

Проверяем результат:

➭ sudo cat /proc/net/pktgen/lo

Params: count 50  min_pkt_size: 1400  max_pkt_size: 1400

frags: 0  delay: 50  clone_skb: 0  ifname: lo

flows: 0 flowlen: 0

queue_map_min: 0  queue_map_max: 0

dst_min: 127.0.0.1  dst_max:

src_min:   src_max:

src_mac: 00:00:00:00:00:00 dst_mac: 00:00:00:00:00:00

udp_src_min: 9  udp_src_max: 9  udp_dst_min: 11100  udp_dst_max: 11150

src_mac_count: 0  dst_mac_count: 0

Flags:

Current:

pkts-sofar: 0  errors: 0

started: 0us  stopped: 0us idle: 0us

seq_num: 0  cur_dst_mac_offset: 0  cur_src_mac_offset: 0

cur_saddr: 0.0.0.0  cur_daddr: 127.0.0.1

cur_udp_dst: 11150  cur_udp_src: 0

cur_queue_map: 0

flows: 0

Result: OK: udp_dst_max=11150

Были заданы порты из диапазона, на котором работало приложение. Часто есть смысл проверить также работу портов с номерами до 1024.

Наконец, запустим генератор:

➭ sudo bash -c 'echo start > /proc/net/pktgen/pgctrl'

Результат:

➭ sudo cat /proc/net/pktgen/lo

Params: count 50  min_pkt_size: 1400  max_pkt_size: 1400

frags: 0  delay: 50  clone_skb: 0  ifname: lo

flows: 0 flowlen: 0

queue_map_min: 0  queue_map_max: 0

dst_min: 127.0.0.1  dst_max:

src_min:   src_max:

src_mac: 00:00:00:00:00:00 dst_mac: 00:00:00:00:00:00

udp_src_min: 9  udp_src_max: 9  udp_dst_min: 11100  udp_dst_max: 11150

src_mac_count: 0  dst_mac_count: 0

Flags:

Current:

pkts-sofar: 50  errors: 0

started: 1887512514145us  stopped: 1887512514494us idle: 0us

seq_num: 51  cur_dst_mac_offset: 0  cur_src_mac_offset: 0

cur_saddr: 127.0.0.1  cur_daddr: 127.0.0.1

cur_udp_dst: 11100  cur_udp_src: 9

cur_queue_map: 0

flows: 0

Result: OK: 348(c348+d0) usec, 50 (1400byte,0frags)

143333pps 1605Mb/sec (1605329600bps) errors: 0

Посмотрим на рис. 24.5. В Wireshark будут видны сгенерированные пакеты:

Рис. 24.5. Сгенерированные пакеты в Wireshark

Данные пакетов нулевые — можно предположить, что искажений в канале нет.

Обычно после запуска работающий генератор необходимо остановить, а выделенные генераторы удалить:

➭ sudo bash -c 'echo stop > /proc/net/pktgen/pgctrl'

➭ sudo bash -c 'echo rem_device_all > /proc/net/pktgen/kpktgend_5'

В примере остановка не обязательна, так как будет сгенерировано фиксированное количество пакетов, и после этого генератор остановится сам.

Следующая команда сбрасывает все генераторы и удаляет связанные устройства:

➭ sudo bash -c 'echo reset > /proc/net/pktgen/pgctrl'

По окончании работы с генератором выгрузим модуль:

➭ sudo bash -c 'modprobe -r pktgen'

Встроенный генератор в Linux не дает возможности задать содержимое пакетов, как это позволяет сделать ping.

По крайней мере до версии 6 ядра включительно генератор не позволял задавать шаблон данных пакета.

Упомянутые в главе 23 фреймворки для kernel bypass эту возможность имеют. Можно использовать генераторы из них. Генератор Pktgen-DPDK вообще позволяет выполнять скрипты на Lua, управляя процессом генерации в зависимости от произвольных условий. В составе кросс-платформенного фреймворка Netmap286 тоже есть свой генератор.

Для Windows, за исключением DPDK, есть по крайней мере одно решение уровня ядра — MiniSMB287. Более полный список генераторов пакетов можно посмотреть на Wikipedia, в статье «Packet Generator»288, и в различных обзорах289.

Scapy

Scapy — очень удобный Python-фреймворк, который можно использовать как интерактивный инструмент или подключать как модуль в свое приложение. На рис. 24.6 показано окно консоли Scapy после запуска.

Рис. 24.6. Окно запуска Scapy

Scapy используется для тестирования безопасности сети и позволяет строить PDU для разных протоколов, которые можно в дальнейшем отправлять и получать ответ.

Например, простейший вариант ping, реализованный прямо в интерактивной оболочке:

➭ sudo scapy -H

Welcome to Scapy (2.5.0)

>>> p = IP(dst='github.com') / ICMP()

>>> res = sr1(p)

Begin emission:

Finished sending 1 packets.

Received 19 packets, got 1 answers, remaining 0 packets

>>> res

<IP  version=4 ihl=5 tos=0x0 len=28 id=18549 flags= frag=0 ttl=54 proto=icmp chksum=0x7460 src=140.82.121.4 dst=192.168.2.13 |<ICMP  type=echo-reply code=0 chksum=0x0 id=0x0 seq=0x0 |>>

В этом примере мы построили IP-пакет с адресом назначения github.com. Scapy выполнит разрешение имен самостоятельно. В пакет мы поместили ICMP-сообщение. По умолчанию это Echo Request. Затем, используя функцию sr1(), мы отправили данный пакет и получили ответ. В сниффере это будет выглядеть, как показано на рис. 24.7.

Рис. 24.7. Дамп работы ping в Scapy

Из примера видно, что Scapy берет на себя множество рутинных операций, к тому же предлагает все имеющиеся возможности языка Python. Например, чтобы получить информацию о функции sr1(), можно использовать функцию help().

Help on function sr1 in module scapy.sendrecv:

sr1(x, promisc=None, filter=None, iface=None, nofilter=0, *args, **kargs)

Send packets at layer 3 and return only the first answer

:param pks: SuperSocket instance to send/receive packets

:param pkt: the packet to send

:param rcv_pks: if set, will be used instead of pks to receive packets.

packets will still be sent through pks

:param nofilter: put 1 to avoid use of BPF filters

:param retry: if positive, how many times to resend unanswered packets

if negative, how many times to retry when no more packets

are answered

:param timeout: how much time to wait after the last packet has been sent

:param verbose: set verbosity level

:param multi: whether to accept multiple answers for the same stimulus

:param prebuild: pre-build the packets before starting to send them.

Automatically enabled when a generator is passed as the packet

В Scapy реализовано множество сетевых примитивов, что позволяет не изобретать велосипед, а пользоваться готовым проверенным кодом там, где это требуется.

Существует также проект Nemesis290, реализованный на C. Он представляет собой утилиту, которая может собирать пакеты. Эта утилита основана на библиотеке libnet291, используемой еще несколькими инструментами, например Ettercap.

Поэтому разработчику данный фреймворк тоже может быть полезен. Причем не только для тестирования особых случаев, но и для реализации сервисных программ, как пример — для работы с TUN/TAP-интерфейсами в главе 23.

Подробнее мы рассмотрим Scapy и другие схожие решения в книге 3, в главах, посвященных безопасности.

Резюме

Ошибки в сети могут проявляться на уровне отдельных узлов и в каналах связи, которые их объединяют. Когда разработчики об этом забывают, у них возникают различные заблуждения, что приводит к ошибкам в программном обеспечении и созданию неэффективных приложений.

Обнаружение ошибок начинается с локализации места их возникновения: приложение это или канал, и иногда требуется не исправлять «ошибки» приложения, а обратиться к сетевому администратору и начать исправление ошибок в сети.

В поиске ошибок помогают средства встроенной диагностики ОС, включая такие «обычные» утилиты, как ping или netstat. Конечно, есть и более сложные утилиты для трассировки, работы с доменными именами, настройки сети. Таких утилит достаточно много.

Для анализа данных, отправленных приложением либо пришедших к нему, полезны сетевые анализаторы, или снифферы, которые мы рассмотрели в главе 23.

Полезными инструментами диагностики являются также отладочные прокси-серверы и некоторые специализированные инструменты, такие как пакеты отладочных инструментов.

Чтобы найти «сложные ошибки», могут быть использованы средства трассировки. Они полезны не только при отладке, но и при оптимизации. К таким инструментам относятся BPF Compiler Collection или BPF toolkit, tcptrace, iptables-tracer, nsntrace. Для отладки WinSock существует хорошая утилита Wsock-trace.

Утилита «общего плана» — strace — позволяет увидеть системные вызовы, которые выполняет приложение, а также имеет фильтры, позволяющие ей отображать только вызовы, относящиеся к сети.

Если необходимо исключить проблемы, возникающие только в пользовательском пространстве, можно использовать средства ядра, например встроенный генератор пакетов Linux.

А фреймворки для создания пакетов, например Scapy, дают возможность интерактивно проверять, как сеть будет реагировать на нестандартные ситуации, что может быть использовано при отладке.

Вопросы и задания

1. Какие основные заблуждения существуют у разработчиков относительно сети и распределенных систем? Каковы последствия этих заблуждений?

2. Где необходимо искать проблемы, когда сетевое приложение не работает?

3. Что обычно является первым шагом в диагностике проблем сетевых приложений?

4. Всегда ли разработчик должен решать сетевые проблемы самостоятельно?

5. Какие инструменты и методы можно использовать для обнаружения ошибок?

6. Как может помочь в диагностике утилита Ping?

7. Как явно потребовать от ping отправки пакетов IPv4, если вызов ping с доменным именем узла использует IPv6?

8. Чем может быть полезна утилита SS? Чем она различается с Netstat?

9. Какие утилиты позволяют работать с доменными именами, выполняя запросы к DNS? Как они могут помочь разработчику?

10. Как называется основная утилита для управления сетью в Linux?

11. Используя какую утилиту можно управлять сетью из командной строки в ОС Windows?

12. Какие проблемы может вызывать межсетевой экран?

13. В чем заключается роль сетевых анализаторов, или снифферов, при разработке сетевых приложений?

14. Перечислите несколько полезных утилит из проекта BCC. Чем они могут помочь при разработке?

15. Какие утилиты и инструменты могут помочь отслеживать события при работе соединений в сетевых приложениях?

16. Каким способом перехватывает вызовы разработанный нами перехватчик? Можно ли организовать перехват на уровне системы?

17. Почему реализованный нами перехватчик может не работать с некоторыми реализациями Netcat? Как это исправить?

18. Как утилита strace позволяет выявить перехватчик? Для чего она еще может быть полезна?

19. В каких случаях генератор пакетов, работающий на уровне ядра, полезнее, чем генератор, работающий на уровне пользователя? Когда предпочтительнее использовать генератор, работающий как обычное приложение, на уровне пользователя?

20. Что такое Scapy? Как она может помочь разработчику сетевых приложений?

21. Как в Scapy сформировать и отправить пакет? А как получить ответ?

22. Изучите, как работает traceroute в вашей ОС. Реализуйте ICMP traceroute, переделав в него одну из утилит ping, реализованных в предыдущих главах.

23. Доработайте перехватчик так, чтобы он не искажал передаваемые данные, а записывал их в файл.

24. Доработайте перехватчик так, чтобы на сервер были отправлены:

a) IP-адрес и порт, используемые сокетом;

б) строки, которые начинаются с USER и PASS;

в) факт закрытия сокета.

25. Установите FTP-сервер, настройте авторизацию, подключитесь с помощью FTP-клиента в пассивном режиме и попробуйте сделать следующее:

a) обнаружить логин и пароль с помощью tcpdump;

б) обнаружить логин и пароль, запустив клиент под написанным ранее перехватчиком.

247 https://packetsender.com/

248 https://ostinato.org/

249 https://nmap.org/nping/

250 https://software.es.net/iperf/

251 https://manpages.ubuntu.com/manpages/bionic/man1/netstress.1.html

252 https://man7.org/linux/man-pages/man8/ping.8.html

253 https://linux.die.net/man/7/capabilities

254 https://linux.die.net/man/8/arping

255 https://fping.org/

256 https://elifulkerson.com/projects/tcping.php

257 https://learn.microsoft.com/ru-ru/sysinternals/downloads/psping

258 https://en.wikipedia.org/wiki/Paping

259 https://learn.microsoft.com/en-us/sysinternals/downloads/whois

260 https://linux.die.net/man/8/traceroute

261 https://clck.ru/3ETqEn

262 https://ru.wikipedia.org/wiki/Pathping

263 https://www.isc.org/download/

264 https://learn.microsoft.com/en-us/sysinternals/downloads/whois

265 https://www.kernel.org/doc/html/latest/networking/pktgen.html

266 https://www.kali.org/tools/hping3/

267 https://itigic.com/ru/hping3-create-tcp-ip-packets-and-perform-dos-attacks-on-linux/

268 https://scapy.net/

269 https://man7.org/linux/man-pages/man1/strace.1.html

270 https://man7.org/linux/man-pages/man2/ptrace.2.html

271 https://man7.org/linux/man-pages/man1/ltrace.1.html

272 https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/logger-and-logviewer

273 https://github.com/mxmssh/drltrace

274 https://processhacker.sourceforge.io/

275 https://www.nektra.com/products/spystudio-api-monitor/

276 http://www.rohitab.com/apimonitor

277 https://github.com/dtrace4linux/linux

278 https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/dtrace

279 https://sourceware.org/systemtap/

280 https://learn.microsoft.com/en-us/windows/win32/etw/event-tracing-portal?redirectedfrom=MSDN

281 https://github.com/gvanem/wsock-trace

282 https://github.com/iovisor/bcc

283 https://learn.microsoft.com/en-us/windows/win32/winsock/winsock-tracing

284 https://sites.google.com/site/linicetool/

285 https://www.kernel.org/doc/html/latest/networking/pktgen.html

286 https://github.com/luigirizzo/netmap

287 https://github.com/minismb/minismb-win64

288 https://en.wikipedia.org/wiki/Packet_generator

289 https://www.ittsystems.com/network-traffic-generator/

290 https://github.com/libnet/nemesis

291 https://github.com/libnet/libnet

Глава 25. Поиск ошибок и обнаружение места сбоя

Самым эффективным инструментом отладки по-прежнему остается тщательное обдумывание в сочетании с грамотно размещенными операторами print.

Брайан Уилсон Керниган, «Unix for Beginners», 1979

Введение

Сетевые приложения по определению всегда распределенные, поэтому отлаживать их вдвойне сложно.

В этой главе мы разберем поиск ошибки на примере простого UDP-клиента и попробуем сделать следующее:

• выяснить, что проблема находится именно в приложении;

• локализовать проблему и устранить.

Кроме того, обсудим методы локализации ошибок с помощью журналирования и встроенной диагностики, а также использование инструментов отладки и трассировки. Узнаем, как улучшить стабильность приложения, используя строгие проверки компилятора, статические анализаторы и более подробное журналирование. Познакомимся с возможностью замены библиотек на отладочные версии для более детальной диагностики, научимся отделять проблемы внутри кода от внешних проблем.

Затем разберем типовые ошибки в сетевых приложениях и научимся эти ошибки устранять. Узнаем, как правильно обрабатывать ошибки в приложениях, проверять результаты операций и решать проблемы с дескрипторами на разных языках программирования.

Мы также подробнее рассмотрим особенности обработки сигналов в Unix-подобных системах, где сигналы могут уведомлять о различных ошибках, таких как обрыв связи.

Правильная обработка ошибок — ключевое условие разработки надежных сетевых приложений, которое требует внимательного отношения на всех этапах. Поэтому в конце главы мы дадим несколько советов, которые помогут разработчику повысить стабильность работы своих приложений, и приведем ряд ссылок на дополнительную литературу по теме отладки.

Некорректный клиент

Устроим переполнение буфера при вводе команды. Пример искусственный, но мы попробуем продемонстрировать приемы отладки на его основе.

Первую часть кода реализуем как обычно:

const size_t command_size = 5;

int main(int argc, const char* argv[])

{

if (argc != 3)

{

std::cout << "Usage: " << argv[0] << " <host> <port>" << std::endl;

return EXIT_FAILURE;

}

const socket_wrapper::SocketWrapper sock_wrap;

const socket_wrapper::Socket sock = {AF_INET, SOCK_DGRAM, IPPROTO_UDP};

if (!sock)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

assert(argv[1]);

const std::string host_name = { argv[1] };

const hostent *remote_host{ gethostbyname(host_name.c_str()) };

sockaddr_in server_addr =

{

.sin_family = AF_INET,

.sin_port = htons(std::stoi(argv[2]))

};

server_addr.sin_addr.s_addr = *reinterpret_cast<const in_addr_t*>(

remote_host->h_addr);

if (0 == connect(sock,

reinterpret_cast<const sockaddr* const>(&server_addr),

sizeof(server_addr)))

{

// Буфер для чтения пользовательского запроса,

// начальный размер которого — 5 символов.

std::string request(command_size, '\0');

std::cout << "Connected to \"" << host_name << "\"..." << std::endl;

Допустим ошибку в цикле отправки данных:

while (true)

{

// !---> Ошибка здесь <---!

std::cin >> &request[0];

request += "\n";

// Позже мы поставим точку останова в отладчике на эту строку.

if (send(sock, request.c_str(),

static_cast<int>(request.length()), 0) < 0)

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

}

}

else // connect() error.

{

std::cerr << sock_wrap.get_last_error_string() << std::endl;

return EXIT_FAILURE;

}

}

Запустим Netcat и посмотрим, что получилось:

➭ ncat -u -4 -l -p 11111

Подключимся к UDP-порту 11111 с помощью клиента. Затем начнем отправлять сообщения, введенные с клавиатуры:

➭ build/bin/b01-ch25-incorrect-udp-client localhost 11111

Connected to "localhost"...

command1

cmd

cmd2

command12345

command1234567890

На стороне Netcat будет получено следующее:

➭ ncat -u -4 -l -p 11111

comma

cmda

cmd2

command1

command12

Мы ожидали получить введенную строку, но получили совершенно иное. Значит, либо канал, либо приложение работает некорректно. Обычно, если приложение реализуется «с нуля», всегда стоит ожидать ошибок в реализации. Автоматизированные тесты вместо ручной проверки на эту ситуацию не повлияют.

Как определить местонахождение проблемы

Предположим, мы определили, что проблема в приложении, а не в канале. Первым делом необходимо выделить крупный блок приложения, потенциально содержащий ошибку.

Понятно, что приложение в принципе работает. При использовании TCP мы также можем убедиться, что соединение устанавливается. Значит, скорее всего, проблема возникает уже после его установки.

В случае UDP результат connect() неинформативен: к сокету просто привязываются адрес и порт. Однако данные передаются, следовательно, send() работает. Таким образом, вероятно, проблема в данных, которые поступают в send().

Проверка данных

Первое, что нужно сделать, — это проверить данные, которые отправляются по сети. Сниффер либо отладочный прокси помогут сразу понять, что данные искажаются где-то в приложении или, в худшем случае, на узле.

На рис. 25.1 хорошо видно, что в канал были отправлены некорректные данные.

Рис. 25.1. Некорректные данные в канале

Главное — увидеть трафик, чтобы понять: ошибка на отправляющей стороне. В современных сетях трафик зачастую шифрованный, но шифрование выполняется не с помощью частных решений, а с помощью библиотек, например OpenSSL. В этом случае необходимо снять шифрование, что, например, умеет делать WireShark и некоторые отладочные прокси.

Сейчас же мы продолжим отладку и рассмотрение дальнейшего процесса.

Отладка

Поставим точку останова на строке, где вызывается send():

-> if (send(sock, request.c_str(), request.length(), 0) < 0)

Нам требуется узнать, что приходит в переменной request. Выясняется, что в ней содержится то же, что получает Netcat. Значит, проблема точно на стороне клиента, а не в канале или на узле.

Выяснив, что проблема в отправляемом сообщении, посмотрим на потоки данных. В рамках одного процесса можно считать, что поток начинается с формирования данных и заканчивается выходом данных за границы процесса: на консоль, в сеть, на запоминающее устройство и т.д. У нас поток заканчивается на send(), а начинается с пользовательского ввода из консоли.

Поставим точку останова на ввод данных.

В пошаговом режиме выполним программу и введем строку, которая отправлялась некорректно: например, "command12345".

Проверив буфер ввода, выясним, что его размер — 5 символов и этот размер не изменяется. Следовательно, в данном случае программист некорректно использовал буфер.

В итоге:

• Размер буфера, в который записывается строка, меньше, чем размер строки.

• Размер определяется константой, заданной в начале программы, поэтому его значение видно не сразу.

• Если посмотреть более внимательно, команда, содержащая пробелы, будет отправляться как несколько команд.

• Строка пишется в буфер, затирая последующие переменные, например поля объекта request, в результате чего какие-то длинные строки могут быть переданы случайно.

Таким образом, мы столкнулись с типичным переполнением буфера, которое, кроме всего прочего, ведет к уязвимости.

Далее поток данных заканчивается получением буфера вида const char* и передачей его на отправку функции send(). Метод c_str(), возвращающий этот буфер, обрезает строку по заданному размеру, и поэтому, как правило, отправляется урезанная команда.

В C++ достаточно внести следующее исправление:

std::getline(std::cin, request);

Размер объекта std::string будет установлен по размеру введенной строки, и переполнений не будет.

Динамический анализ

Существует много различных инструментов динамического анализа, то есть анализа приложения в процессе его выполнения: библиотека tcmalloc из пакета gperftools, DrMemory292 и прочие.

Одним из популярных пакетов является Valgrind, который предоставляет целый набор инструментов через единый интерфейс. Он позволяет трассировать вызовы, чтобы найти узкие места, выявлять гонки потоков, определять неэффективное использование CPU кэшей и т.д.

Мы посмотрим, как он используется, на примере инструмента memcheck. Этот инструмент позволяет обнаружить проблемы, перечисленные ниже:

• Доступ к еще не выделенной или уже освобожденной памяти.

• Использование неинициализированных значений.

• Повторное освобождение памяти.

• Наличие утечек памяти.

• Использование перекрывающихся блоков памяти в функциях, которые это не поддерживают, например в memcpy().

Инструмент не выбран явно, и memcheck запустится по умолчанию:

➭ valgrind build/bin/b01-ch25-incorrect-udp-client localhost 11111

==3993862== Memcheck, a memory error detector

==3993862== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.

==3993862== Using Valgrind-3.23.0 and LibVEX; rerun with -h for copyright info

==3993862== Command: build/bin/b01-ch25-incorrect-udp-client localhost 11111

==3993862==

Connected to "localhost"...

verybigcommandverybigcommandverybigcommandverybig

commandverybigcommandverybigcommandverybigcommandverybigcommand

==3993862== Invalid read of size 8

==3993862==    at 0x4030E0: socket_wrapper::SocketWrapper::get_last_error_string[abi:cxx11]() const (socket_wrapper.cpp:40)

==3993862==    by 0x40272C: main (main.cpp:61)

==3993862==  Address 0x6d6f636769627972 is not stack'd, malloc'd or (recently) free'd

==3993862==

==3993862==

==3993862== Process terminating with default action of signal 11 (SIGSEGV): dumping core

==3993862==  General Protection Fault

==3993862==    at 0x4030E0: socket_wrapper::SocketWrapper::get_last_error_string[abi:cxx11]() const (socket_wrapper.cpp:40)

==3993862==    by 0x40272C: main (main.cpp:61)

==3993862==

==3993862== HEAP SUMMARY:

==3993862==     in use at exit: 82,620 bytes in 22 blocks

==3993862==   total heap usage: 36 allocs, 14 frees, 101,360 bytes allocated

==3993862==

==3993862== LEAK SUMMARY:

==3993862==    definitely lost: 8 bytes in 1 blocks

==3993862==    indirectly lost: 0 bytes in 0 blocks

==3993862==      possibly lost: 0 bytes in 0 blocks

==3993862==    still reachable: 82,612 bytes in 21 blocks

==3993862==         suppressed: 0 bytes in 0 blocks

==3993862== Rerun with --leak-check=full to see details of leaked memory

==3993862==

==3993862== For lists of detected and suppressed errors, rerun with: -s

==3993862== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

[1]    3993862 segmentation fault (core dumped)  valgrind build/bin/b01-ch25-incorrect-udp-client localhost 11111

То есть в Valgrind передается путь к приложению и параметры приложения. Затем приложение запускается под его управлением.

Для выбора инструмента существует опция --tool:

➭ valgrind --tool=callgrind build/bin/b01-ch25-incorrect-udp-client

localhost 11111

Такая команда запустит инструмент Callgrind, передав ему путь к приложению и параметры приложения.

После запуска мы ввели длинную команду, после чего приложение завершилось с ошибкой. В журнале видно, что приложение упало на строке 61, при выводе ошибки через SocketWrapper::get_last_error_string(). Это, судя по всему, не то место, где возникла проблема.

Выше можно увидеть сообщение Invalid read of size 8, которое говорит, что ошибка произошла при попытке чтения 8 байт памяти. Наличие такого сообщения показывает, что ошибка допущена в приложении и связана либо с некорректным выделением памяти или ее слишком ранним освобождением, либо с доступом за границы выделенной памяти. Знание этого позволяет быстрее найти и локализовать ошибку.

В нашем тестовом приложении такое сообщение явно указывает на то, что работа с буфером производится некорректно.

Подмена функций и библиотек

Некоторые библиотеки, а также отдельные функции из LibC могут заменяться на отладочные версии — более медленные, но с расширенной диагностикой. Так, например, аллокаторы памяти обычно заменяют на специальные. Пример — DMalloc293. Это отладочный malloc, или Debug Malloc. Он предоставляет возможность отслеживания утечек памяти, обнаружения записи после выделенного блока, ведения статистики и т.д.

Ужесточение проверок

Большинство современных компиляторов поддерживают возможность динамического анализа кода за счет некоторого замедления скорости выполнения.

Санитайзеры, как и memcheck, позволяют выявлять некорректные выделения памяти, утечки, использование стековых переменных после возврата из функции, переполнение стека и кучи и т.д. Большую часть этих задач выполняет Address Sanitizer. В некоторых компиляторах существуют и другие санитайзеры, например UBSan для выявления неопределенного поведения или TSan для выявления состояний гонки между потоками.

В компиляторах GCC и CLang санитайзер адресов включается опцией -fsanitize=address, а в компиляторе MSVC — опцией /fsanitize=address.

В GCC, кроме того, при включении санитайзера необходимо подключить библиотеку asan и желательно добавить опцию -fno-omit-frame-pointer, чтобы получать нормальные стек-трейсы.

Также при запуске приложения санитайзеры могут принимать опции через переменные, например ASAN_OPTIONS. Так, при включенном санитайзере следующая команда перед запуском приложения выведет справку по опциям GCC санитайзера:

➭ ASAN_OPTIONS=help=1 build/bin/b01-ch25-incorrect-udp-client localhost 11111

Available flags for AddressSanitizer:

quarantine_size

— Deprecated, please use quarantine_size_mb. (Current Value: -1)

quarantine_size_mb

— Size (in Mb) of quarantine used to detect use-after-free errors.

...

Санитайзеры помогают не всегда. Например, в нашем тестовом приложении они могут и не обнаружить, казалось бы, явную ошибку. Но они бывают полезны для отладки и поиска ошибок.

В случае, если приложение реализуется на языке C и компилируется GCC версии 4.0 и выше, также имеет смысл определить макрос _FORTIFY_SOURCE, который при включенных оптимизациях компилятора активирует проверку на переполнение буфера в таких функциях, как memcpy(), strcpy(), sprintf(), и прочих.

Безопасное значение макроса — _FORTIFY_SOURCE=1. В этом случае выполняются только те проверки, которые не могут сломать приложение.

Если определено _FORTIFY_SOURCE=2, добавляются более агрессивные проверки.

При таком значении опции некоторые программы, реализованные не по стандартам, могут завершаться с ошибкой.

_FORTIFY_SOURCE=3 включает проверки границ буфера на переполнение и прочие, что позволяет обнаружить и нашу ошибку.

В ОС Windows также предлагается вместо обычных «консольных» функций использовать аналогичные им безопасные функции, которые имеют суффикс _s. Они не являются частью стандарта C, но если использовать не их, компилятор будет выдавать предупреждения об устаревших вызовах.

Выключаются эти предупреждения определением макроса _CRT_SECURE_NO_WARNINGS.

Статический анализ

Большинство современных компиляторов выполняют множество проверок в процессе компиляции. Это и есть статический анализ программы.

Как правило, выполняется проверка соответствия типов, а также использования «опасных» и неоднозначных выражений.

Можно пойти дальше и проводить более глубокий анализ. Это замедлит сборку приложения, но эти затраты компенсируются повышением безопасности и ускорением поиска ошибок.

Иногда подобные утилиты поставляются разработчиками компилятора. Например, утилита clang-tidy, которая проводит статический анализ, проверяет форматирование и может сама исправлять некоторые ошибки в коде.

Существуют как бесплатные утилиты для C++: Сppcheck, cpplint, более «тяжелый» и мощный OCLint, так и платные. Из последних стоит выделить достаточно известный анализатор PVS-Studio, появившийся в 2008 году и отличающийся весьма неплохим качеством анализа. Он поддерживает работу с четырьмя C-подобными языками: C, C++, C# и Java.

Несмотря на то что PVS-Studio является платным решением и распространяется только по модели B2B, для некоммерческих проектов анализатор может быть бесплатным294.

PVS-Studio позволяет находить такие ошибки, как:

• Выход за границы массива и переполнение буферов.

• Разыменование нулевых указателей и разыменование переменных без проверки.

• «Мертвый код» и неиспользуемые переменные.

• Ошибки синхронизации.

• Неопределенное поведение и неинициализированные переменные.

• Ошибки сериализации и десериализации.

• Неправильная работа с WPF на ОС Windows.

• Ошибки работы с типами.

• Ошибки в приоритетах операций.

• Утечки ресурсов.

• Проблемы с безопасностью.

• Опечатки

и другие.

На сайте https://pvs-studio.ru/ru/docs/warnings/ приведен полный список ошибок.

Анализатор можно использовать как для анализа проектов в CI, так и для интерактивной работы. Кроме того, поддерживается интеграция с популярными IDE: Visual Studio, VSCode, QtCreator, CLion, Rider и IntelliJ IDEA. Инструмент работает на Windows, Linux и MacOS X.

Еще одно интересное решение — анализатор Svace от ИСП РАН295, который обеспечивает анализ путей выполнения и высокое покрытие кода. Помимо статического анализа, Svace помогает выявлять недекларированные возможности, что полезно, когда приложение нужно сертифицировать.

В дополнение к анализатору поставляется графический интерфейс Svacer. Анализатор работает на Linux и ОС Windows и поддерживает множество платформ. Кроме C++, он может анализировать код на C#, Go, Java, Kotlin. Также в ближайшей перспективе — анализ Python-кода. С 2015 года этот анализатор использует компания Samsung.

Статические анализаторы есть и для Python. Из них чаще всего используются PyLint, Flake8, ruff, а для проверки типов — MyPy и PyRight.

Улучшение журналирования

Если использовать отладчик неудобно, его можно заменить выводом буфера на консоль. Вывод на консоль лучше включать при отладке и выключать, когда поставляется релиз.

Например, так:

#ifdef DEBUG

std::cout << request << std::endl;

#endif

Но это решение тоже не идеально. Хотелось бы иметь журнал, протокол или лог работы программы, среди прочего позволяющий делать следующее:

• Получать строку, имя файла и функции программы, в которых выполнялась запись в протокол, а также дату и время записи. В случае, если произошел сбой, это позволит выявить последние выполняющиеся команды гораздо проще, а также определить, какие данные были им переданы, если знать, что и в какое время отправлялось.

• Журналировать не только в отладочной версии, но и в релизе. Включать и выключать журналирование по мере необходимости.

• Иметь несколько уровней отладки: самый подробный, только предупреждения, только ошибки и др.

• Выводить отладочные данные не только в консоль, но и в разные источники, например в syslog.

• Добавлять к отладочным данным произвольное состояние процесса, например стек вызовов.

• Отправлять информацию о сбоях по сети.

Правильное журналирование позволяет быстро локализовать место ошибки и сузить круг возможных причин.

Несколько советов по журналированию

• В программе должна быть опция, которая позволяет выбрать уровень журналирования без перекомпиляции. Обычно достаточно 3–5 уровней:

• TRACE — для очень подробной трассировки, что используется в основном разработчиками;

• DEBUG — для отладки, поиска типовых неисправностей и ошибок конфигурирования;

• INFO — информационные сообщения, которые выводятся при начале либо завершении определенных этапов бизнес-логики;

• WARNING — предупреждения о потенциальных ошибках и, возможно, неправильной конфигурации, неожиданных действиях;

• ERROR — фатальные ошибки, необработанные исключения. Часто выделяют уровень FATAL, используемый для журналирования ошибок, которые мешают программе продолжать работу. Прочие ошибки журналируются на уровне ERROR.

• Журналы с разным уровнем журналирования лучше записывать в разные сущности. Например, веб-сервер Apache для протоколов доступа использует файл access.log, а для протокола ошибок — error.log. Это не всегда применимо, так как журналы могут сохраняться, например, в базу данных, но и в данном случае лучше их сохранять, например, в отдельные таблицы.

• Вести журналы необходимо на правильном уровне абстракции, то есть на таком, который обладает всей необходимой информацией для формирования записи:

• Бизнес-логика должна вести логи бизнес-процессов.

• Драйверы и адаптеры должны вести логи обращений и вызовов.

• Низкоуровневые примитивы должны вести логи низкоуровневых вызовов.

• Желательно подробно журналировать участки, в которых производится взаимодействие с другими процессами:

• запуск приложений и получение результата;

• выполнение запроса к удаленной системе;

• обращение с запросом от сторонней системы.

• Необходимо журналировать запуск приложения и его завершение, чтобы понять, были ли они корректными.

• Изменение состояния процесса согласно бизнес-логике тоже стоит журналировать. Если клиент выполнил запрос к веб-серверу, это надо записать в журнал. Такая ситуация вполне обычна, и подходит уровень DEBUG либо INFO. При этом записывается следующая информация: данные предыдущего состояния, причина выполнения перехода и данные нового состояния.

• К логам желательно добавлять информацию, которая помогает выявить проблему. Например, если к системе обращаются с запросом, желательно сохранить его либо его часть. Также нужно сохранить контекст запроса: кто обращался, откуда, результат обращения и ответ.

• В журнале обязательно должна присутствовать версия программы и, по возможности, ее конфигурация в момент запуска, а также командная строка.

• Обязательно журналировать места, которые относятся к безопасности. Например, попытки входа пользователя, особенно неуспешные.

• Нельзя журналировать:

• Чувствительные данные, такие как пароли. Пользователи могут ошибиться в одном символе либо использовать старый пароль. Такие данные в протоколе — подарок для взломщика.

• Пользовательские данные без проверки. Если их объем очень велик, журналирование сильно замедлит работу системы.

• Персональную информацию, которая может выделить пользователя, если это противоречит соглашениям и законам, таким как GDPR296 или ФЗ «Закон о персональных данных» № 152-ФЗ от 27.07.2006.

• Всегда стоит помнить, что утечка журналов может повлечь серьезные проблемы безопасности, поэтому необходимо ограничивать доступ к ним и предоставлять его только тем лицам, кому он действительно требуется.

Для многих языков API журналирования уже содержится в их стандартных библиотеках. Характерным примером является Python. В нем классы журналирования содержатся в модуле logging. Типовой вариант использования данного модуля делится на два этапа.

Первый — инициализация, в процессе которой задается как минимум уровень журналирования, хотя могут быть заданы и другие параметры логгера:

import argparse

import logging

if __name__ == '__main__':

# Добавление опции `--verbose`.

parser.add_argument('--verbose', '-v', default=False, action='store_true',

help='More verbose logging')

args = parser.parse_args()

# Инициализация системы журналирования уровнем журналирования.

logging.basicConfig(format='%(asctime)s %(message)s',

datefmt='%d.%m.%Y %H:%M:%S',

level='DEBUG' if args.verbose else 'INFO')

Параметры могут быть заданы, например, из опций командной строки.

Второй — непосредственно журналирование:

# Простой вариант.

logging.info('Logging test!')

# Вариант с именем модуля, позволяющий легче определить место сбоя.

# Переменная уровня модуля, которая инициализируется его именем.

LOGGER = logging.getLogger(__name__)

# Запись данных в журнал.

LOGGER.debug('Trying to load "%s"', filename)

Внимание! В аргументах методов логгера нельзя использовать форматирование строк.

Отображение аргументов задается строкой формата, как в C-функции printf(), а сами аргументы перечисляются уже после строки формата. Это сделано для того, чтобы в режимах ниже установленного не выполнялось преобразование сложных объектов, например списков, в строки. Оно занимает много времени и может сильно замедлить работу программы в релизе.

Увы, в стандартной библиотеке шаблонов C++ журналирование не реализовано. Зато есть множество сторонних библиотек журналирования297, из которых стоит отметить следующие:

• Boost.Log298 — известная и зрелая библиотека в составе Boost.

• GLog299 — Google Logging Library.

• Spdlog300 — header-only-библиотека для журналирования.

• Baical P7301 — библиотека протоколирования и телеметрии, быстрая и не слишком требовательная к ресурсам.

• PLog302 — маленькая header-only-библиотека, содержащая менее 1000 строк кода.

Вы можете использовать любую из названных или многих других библиотек, которая подходит для ваших задач. Существует немало сравнений таких библиотек.

О поиске ошибок

Не стоит думать, что поиск ошибок — простое занятие. Пример с некорректной реализацией сервера мы специально предельно упростили. В реальных системах поиск ошибки может занять несколько дней и осложняться множеством факторов, таких как:

• Многопоточность или многопроцессность системы, которая порождает состояние гонки и ошибки, ускользающие при работе отладчика.

• Невозможность отладки, например, из-за того, что приложение работает в рамках прошивки и ресурсов на то, чтобы поставить даже серверную часть отладчика, не хватает.

• Объем кодовой базы системы, исчисляющийся миллионами строк. При этом может отсутствовать как связь с разработчиками начальной версии, так и документация.

• Отсутствие части кодовой базы, когда некоторые компоненты системы доступны только в бинарном виде.

• Отсутствие модульных тестов, что не позволяет определить, корректно ли работает функция и не сломана ли она изменениями.

• Появление ошибки только спустя время либо при большой загрузке, которую нельзя или сложно обеспечить локально.

• Появление ошибки только в конкретном окружении, которое нельзя повторить даже на тестовом стенде. Система может работать на действующем сервере, и его нельзя останавливать.

• Очень тесная привязка к аппаратуре, например ошибки в обработчиках прерываний. Это больше относится к коду в ядре. Но такие ошибки, например, в драйверах новых сетевых карт, могут проявляться на уровне пользователя, вызывая искажение или потерю данных.

Сетевые приложения, которым нужно обрабатывать большие объемы данных или запросов, часто реализуют как многопоточные и асинхронные. И по своей сути сетевые приложения представляют собой распределенные системы, в которых компоненты работают параллельно. Соответственно, для них характерны все ошибки, известные в параллельных системах, а также ошибки, вытекающие из ненадежности каналов и непредсказуемости поведения сети.

Некоторые распространенные ошибки

• Перечислим несколько типовых ошибок, которые допускают разработчики сетевых приложений:

• Неправильная обработка пользовательского ввода. Такие ошибки встречаются чаще всего и обычно являются критическими. Могут приводить к переполнению буфера и выполнению произвольного кода.

• Отсутствие проверки кодов возврата функций, особенно recv(), send() и им подобных. Они не всегда будут возвращать данные того размера, который указал программист, даже если используется флаг MSG_WAITALL.

• Отсутствие проверки значения errno на EINTR, когда функции recv(), send() и подобные возвращают –1. Приложение считает, что вызов функции завершился с ошибкой. А ее достаточно всего лишь перезапустить либо настроить автоматический перезапуск по сигналу.

• Отсутствие обработки сигналов, особенно SIGPIPE, в Unix-подобных системах. В реальном приложении отсутствие обработки SIGPIPE приводит к аварийному завершению программы. В примерах из книги данная ошибка тоже встречается.

• Неверная конкуренция за ресурс параллельно работающих процессов. Имеются в виду «процессы» в широком смысле. Например, выделение или освобождение памяти в обработчиках сигналов явно ведет к проблемам.

• Дескриптор завершенного потока остается открытым, то есть SIGCHLD не обработан, что приводит к утечке ресурсов и в итоге к аварийному завершению приложения.

• Дескрипторы сокетов в новых процессах остаются открытыми. Это также ведет к утечке и последующему исчерпанию дескрипторов. Чтобы такого не происходило, при открытии сокета в ОС, где это поддерживается, например в Linux, устанавливайте флаг SOCK_CLOEXEC или просто установите F_CLOEXEC на дескриптор.

Если не допускать хотя бы этих ошибок, ваши приложения станут работать намного стабильнее.

Обработка ошибок при вызове сетевых функций

Поиск ошибок в приложении упрощает грамотная реализация их обработки, а также журналирования, обработки сигналов и прочих средств диагностики.

В этом разделе мы поговорим о важной теме — работе с ошибками, которые могут возникнуть при вызове различных функций и методов сетевого API.

В правильно написанном сетевом приложении:

• Результат выполнения каждой вызванной функции должен быть проверен.

• В случае проблем должна быть выдана ошибка, либо приводящая к завершению приложения, либо обрабатываемая иным способом.

Работа с ошибками в C и в POSIX API

Каждая сокетная функция возвращает результат, например:

ssize_t send(int sock_fd, const void *buf, size_t len, int flags);

Тип возвращаемого значения — ssize_t в POSIX или int в ОС Windows.

Как правило, в случае функций POSIX отрицательные значения кодируют состояние ошибки, для которого требуется корректная обработка.

Из предыдущих глав было видно, что при ошибке возвращается –1 или константа INVALID_SOCKET в ОС Windows.

Типы возвращаемых значений

В новых Unix-системах возвращаемое значение обычно имеет тип ssize_t, который объявлен как long, тогда как size_t — как unsigned long. То есть size_t будет включать почти в два раза больше положительных значений, чем ssize_t.

Обычно для разработчика нет особой разницы: даже функции приема данных не вернут блок, размер которого больше, чем может содержать тип long, то есть 2 Гбайт. Если же значение выходит за эти границы, поведение будет зависеть от реализации, но обычно система просто возвращает ошибку EINVAL.

Если это требуется учитывать, получить максимальные значения можно, используя константы:

• SIZE_MAX — для типа size_t. Значения типа size_t гарантированно лежат в границах [0, SIZE_MAX].

• SSIZE_MAX — для типа ssize_t. Значения ssize_t гарантированно лежат в границах [-1, SSIZE_MAX].

• _POSIX_SSIZE_MAX — минимально возможное значение константы SSIZE_MAX на POSIX-системах. Это значение совсем небольшое по сравнению с SIZE_MAX. Обычно 32767.

Две последние константы в POSIX-системах определены в файле limits.h.

Для некоторых функций, например connect(), может быть явно указано, что в случае успешного завершения будет возвращен 0.

Каждая ошибка имеет код, по которому ее можно идентифицировать. В POSIX этот код хранится в устанавливаемой функциями переменной errno:

#include <cerrno>

int main()

{

...

if (connect(sock, reinterpret_cast<const sockaddr* const>(&server_addr),

sizeof(server_addr)))

{

std::cout << "Error code: " << errno << std::endl;

}

}

Переменная errno определена как макрос. В С++ до стандарта 11 он раскрывается в статическую переменную, а в более новых стандартах — в переменную, которая хранится в локальном хранилище потока, что делает использование errno потокобезопасным: вызов какой-либо функции в одном потоке не изменит данную переменную глобально.

Внимание! До C++11 доступ к данной переменной из разных потоков должен был согласовываться. А с учетом того, что функции ввода-вывода ее изменяют, весь блок — вызов и проверку результата — требовалось выполнять, например, под мьютексом.

Если вызов функции завершен без ошибки, errno будет сброшена в 0.

Коды ошибок содержатся в заголовочном файле errno.h, а для C++ — в cerrno. Посмотреть их можно на cppreference303.

В C для печати сообщения об ошибке существует функция perror():

#include <cerror>

void perror(const char *s)

Параметр s — строка, которая будет добавлена к сообщению.

Например:

#include <cstdio>

int main()

{

...

if (0 != connect(sock,

reinterpret_cast<const sockaddr* const>(&server_addr),

sizeof(server_addr)))

{

perror("Simple client error");

}

}

Если подключение не удастся установить из-за ошибки, будет выведено соответствующее сообщение. Например, в случае отсутствия сервера: Simple client error: Connection refused.

Но гораздо удобнее формировать текстовое сообщение без вывода в консоль и лишь затем выводить его так, как требует интерфейс приложения. Например, в графическое окно.

Чтобы получить текстовое сообщение, используют функцию:

#include <cstring>

char *strerror(int errnum);

Она сама выделяет буфер, который может переиспользовать, и поэтому не является потокобезопасной.

Причина того, что буфер выделяется динамически, а не с возвратом указателей на константные строки, — пользовательские ошибки, например сообщения типа "Unknown error 3542", которые не могут быть записаны в таблице.

Но С++, начиная со стандарта C++11, должен поддерживать функции strerror_s() и strerrorlen_s():

#include <cstring>

errno_t strerror_s(char *buf, rsize_t bufsz, errno_t errnum);

size_t strerrorlen_s(errno_t errnum);

Они потокобезопасны и могут использоваться следующим образом:

#define __STDC_WANT_LIB_EXT1__ 1

#include <cstring>

...

#if defined(__STDC_LIB_EXT1__)

std::string buffer(std::strerrorlen_s(errno) + 1, '\0');

std::strerror_s(&buffer[0], buffer.size(), errno);

#endif

Эти функции поддерживают далеко не все компиляторы, и приходится использовать потокобезопасный вариант функции strerror() — функцию strerror_r(). Она реентерабельна, то есть ее можно параллельно вызывать из нескольких потоков. Подробнее о таких функциях см. во врезке о _r-функциях в главе 1.

С ней есть проблемы304: прототип и поведение могут различаться в зависимости от реализации:

#include <cstring>

...

// Макрос для проверки варианта функции.

#if (_POSIX_C_SOURCE >= 200112L) && ! _GNU_SOURCE

// POSIX-вариант.

int strerror_r(int errnum, char *strerrbuf, size_t buflen);

#else

// GNU-вариант.

char *strerror_r(int err_num, char *buf, size_t buflen);

#endif

Эта функция записывает строку ошибки в предварительно выделенный буфер. Именно такой вариант получения строки ошибки реализован в классе SocketWrapper для Unix-подобных систем:

std::string get_last_error_string() const override

{

std::string buffer(err_buffer_size, '\0');

#if (_POSIX_C_SOURCE >= 200112L) && !_GNU_SOURCE

// POSIX.

::strerror_r(get_last_error_code(), &buffer[0], buffer.size());

return buffer;

#else

// GNU.

return ::strerror_r(get_last_error_code(), &buffer[0], buffer.size());

#endif

};

Внимание! В некоторых реализациях GNU-вариант выделяет буфер не всегда, то есть функция может вернуть как указатель на выделенный буфер, так и указатель на строку из статической таблицы, поэтому необходимо использовать возвращаемое значение функции. В POSIX-варианте, наоборот, должен использоваться адрес буфера.

Пример, который учитывает особенности обеих реализаций:

#include <cstring>

int main()

{

...

if (0 != connect(sock,

reinterpret_cast<const sockaddr* const>(&server_addr),

sizeof(server_addr)))

{

std::string buffer(256, '\0');

#if (_POSIX_C_SOURCE >= 200112L) && ! _GNU_SOURCE

strerror_r(errno, buf.data(), buf.size());

std::cerr << buf << std::endl;

#else

std::cerr << strerror_r(errno, buf, sizeof(buf)) << std::endl;

#endif

}

}

В случае неудачного подключения будет выведена причина ошибки, например: Connection refused.

Сообщения, генерируемые функциями, зависят от локали, и если установить локаль выше вызова нужного API, можно получить сообщение на требуемом языке:

#include <cstring>

int main()

{

...

// Установить русскую локаль:

// — Кодировка utf8 — для Linux.

// — Кодировки cp1251 либо cp866 для Windows.

std::setlocale(LC_ALL, "ru_RU.utf8");

...

if (0 != connect(sock,

reinterpret_cast<const sockaddr* const>(&server_addr),

sizeof(server_addr)))

{

std::string buffer(256, '\0');

std::cerr << strerror_r(errno, buf.data(), buf.size()) << std::endl;

}

}

На экран будет выведено сообщение: «В соединении отказано».

Функция установки локали тоже меняет errno, и работать с такими функциями неудобно. Но существует функция strerror_l(), в которую можно передать нужную локаль явно:

char *strerror_l(int errnum, locale_t locale);

Кроме того, существуют две специфичные для GNU-систем функции:

#if defined(_GNU_SOURCE)

// Возвращает указатель на строку, которая описывает переданный код ошибки.

// Строка не будет переведена с локали C.

const char *strerrordesc_np(int errnum);

// Возвращает указатель на мнемоническое имя кода ошибки.

// Например, для кода EPERM, который обычно равен 9972,

// будет возвращена строка "EPERM".

const char *strerrorname_np(int errnum);

#endif

В ОС Windows для получения результата есть функция WSAGetLastError()305. Это обертка над функцией GetLastError(). Она используется для совместимости вместо GetLastError(), которой в 16-битных версиях Windows не было.

В SocketWrapper данная функция используется так:

// Получить код ошибки WinSock.

int get_last_error_code() const override { return WSAGetLastError(); }

Из кода, возвращенного функцией WSAGetLastError(), можно получить текстовое сообщение, используя функцию FormatMessage()306:

#include <winbase.h>

DWORD FormatMessage(

DWORD dwFlags,

LPCVOID lpSource,

DWORD dwMessageId,

DWORD dwLanguageId,

LPTSTR lpBuffer,

DWORD nSize,

va_list *arguments

);

Параметры функции FormatMessage():

• dwFlags — флаги, управляющие интерпретацией lpSource и форматированием результата:

• FORMAT_MESSAGE_ALLOCATE_BUFFER — выделить буфер для хранения сообщения. Минимальное количество символов для буфера хранится в nSize, максимальный размер — 128 Кбайт.

• FORMAT_MESSAGE_ARGUMENT_ARRAY — параметр arguments не структура va_list, а указатель на массив аргументов.

• FORMAT_MESSAGE_FROM_HMODULE — указывает, что параметр lpSource — дескриптор модуля, содержащий таблицу сообщений в ресурсах. Исключает FORMAT_MESSAGE_FROM_STRING.

• FORMAT_MESSAGE_FROM_STRING — говорит о том, что параметр lpSource — строка. Исключает FORMAT_MESSAGE_FROM_HMODULE.

• FORMAT_MESSAGE_FROM_SYSTEM — форматировать системное сообщение.

• FORMAT_MESSAGE_IGNORE_INSERTS — игнорировать аргументы, а подстановочные символы (например, %1) оставлять без изменений.

• FORMAT_MESSAGE_MAX_WIDTH_MASK — игнорировать разрывы строк.

• lpSource — источник сообщения.

• dwMessageId — идентификатор сообщения, если сообщение получается не из строки.

• dwLanguageId — идентификатор языка сообщения.

• lpBuffer — выходной буфер. Может быть выделен автоматически.

• nSize — размер выходного буфера, если не задан флаг FORMAT_MESSAGE_ALLOCATE_BUFFER, иначе минимальное количество символов TCHAR.

• arguments — аргументы, которые будут подставлены вместо символов %1, %2, %3 и т.д.

Функция вернет количество символов TCHAR в буфере в случае успеха; иначе 0. Подробно она рассмотрена в MSDN, нам же интересна только в комбинации с флагами FORMAT_MESSAGE_ALLOCATE_BUFFER и FORMAT_MESSAGE_FROM_SYSTEM. Первый флаг заставляет функцию выделить буфер под сообщение, второй — сохранить в этот буфер текст системной ошибки.

Рассмотрим, как получение ошибки для Windows реализовано в SocketWrapper:

std::string get_last_error_string() const override

{

TCHAR *s = nullptr;

// Получить сообщение по коду, буфер выделяется функцией.

if (!FormatMessage(

FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |

FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, get_last_error_code(),

MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US),

reinterpret_cast<LPTSTR>(&s), 0, nullptr))

{

// GetLastError() используется, потому что ошибка вызова

// FormatMessage() не относится к ошибкам сети.

throw std::system_error(GetLastError(), std::system_category(),

"FormatMessage()");

}

// Выделенный буфер следует освободить, что будет сделано автоматически.

const auto on_scope_exit = scope_guard([s](void *) { LocalFree(s); });

assert(s != nullptr);

#if defined(UNICODE)

// Вариант для широких символов, так как определен макрос UNICODE.

std::ostringstream res;

// Преобразование в char-буфер.

while (wchar_t *st = s; st != L'\0')

{

res << std::use_facet<std::ctype<wchar_t>>(loc).narrow(*st++, '?');

}

return res.str();

#else

// Скопировать буфер в строку при ее создании.

return std::string(s);

#endif

};

После того как сообщение сформировано и выведено, необходимо освободить буфер, вызвав функцию LocalFree(). Это делает экземпляр scope_guard при выходе из области видимости.

Работа с ошибками функции getaddrinfo()

Для получения кода и текста ошибки при работе с getaddrinfo() используются отдельные функции:

• gai_error() — получить код ошибки из результата getaddrinfo_a().

• gai_strerror() — получить строку ошибки из кода, возвращенного getaddrinfo() либо gai_error(). Эту функцию мы уже рассматривали.

Посмотрим на определение:

#define _GNU_SOURCE

#include <netdb.h>

struct gaicb

{

const char            *ar_name;

const char            *ar_service;

const struct addrinfo *ar_request;

struct addrinfo       *ar_result;

};

int gai_error(gaicb *req);

const char *gai_strerror(int errcode);

В функцию gai_strerror() нужно подставить код, возвращенный блокирующим вызовом getaddrinfo(). А функция gai_error() принимает список, который будет возвращен по завершении асинхронного вызова getaddrinfo_a(). Если вызов завершился с ошибкой, функция вернет ее код.

Этот список можно проверять и в процессе выполнения функции getaddrinfo_a(). Функция gai_error() будет возвращать EAI_INPROGRESS, если асинхронный вызов getaddrinfo_a() еще не завершился. Подробнее асинхронные вызовы будут описаны в книге 2.

Работа с ошибками в C++

В C++ при возникновении ошибок принято генерировать исключения. До C++11 для этого можно было использовать исключения класса std::logic_error или std::runtime_error, их наследников, а также отдельных типов.

Начиная со Стандарта C++11, в STL добавлена библиотека для работы с различными системными исключениями. Ее заголовочный файл — system_error. Рекомендуется использовать именно данную библиотеку.

Для нас интересен класс std::system_error — тип исключения, генерируемого различными функциями, которые обычно взаимодействуют с ОС. В конструкторе он может принимать:

• код ошибки — errno;

• код ошибки и пользовательское сообщение;

• код ошибки, категорию ошибки и пользовательское сообщение;

• код ошибки и категорию ошибки.

Категории ошибок — наследники класса std::error_category, который содержит метод name(), возвращающий имя категории. Метод message() возвращает сообщение об ошибке, а также атрибуты с платформенно-зависимыми и независимыми кодами ошибок.

Категории бывают следующими:

• std::generic_category — универсальная категория для ошибок. Метод name() возвращает строку generic. Используется для кодов, соответствующих POSIX-ошибкам.

• std::system_category — ошибки, сгенерированные операционной системой. Метод name() возвращает строку system.

• std::future_category — категория ошибок, связанных с фьючерсами и промисами.

• std::iostream_category — категория для ошибок потоков ввода-вывода iostream.

Для генерации системных ошибок имеет смысл использовать system_error таким образом:

#include <system_error>

...

try

{

int sock = socket(...);

// Не удалось открыть сокет.

if (-1 /* INVALID_SOCKET */ == sock)

throw std::system_error(errno, std::system_category(),

"Opening interface");

...

}

catch(const std::system_error& e)

{

std::cerr

<< "Caught system_error with code " << e.code()

<< " meaning " << e.what() << '\n';

}

Например, если сгенерировать исключение с ошибкой доступа:

throw std::system_error(EACCES, std::system_category(), "Opening interface");

Результат вызова метода исключения what() будет следующим: Opening interface: Permission denied.

В случае прочих ошибок лучше использовать другие классы исключений. Например, для генерации ошибок в логике приложения хорошо подойдет класс std::logic_error.

Stacktrace

Отображение стека вызовов — одно из нововведений С++23, облегчающее нахождение ошибок. Изначально он было реализован в Boost и пришел в Стандарт оттуда.

#include <algorithm>

#include <iostream>

#include <stacktrace>

int main()

{

auto trace = std::stacktrace::current();

auto empty_trace = std::stacktrace{};

// Напечатать стек-трейс.

std::for_each(trace.begin(), trace.end(), [](const auto& f)

{

std::cout << f << '\n';

});

if (empty_trace.begin() == empty_trace.end())

std::cout

<< "stacktrace 'empty_trace' is indeed empty."

<< std::endl;

}

Ошибку не всегда просто найти. Однако если рассмотреть несколько кадров стека, ошибка становится заметной и понятно, где она появилась.

Обработка сетевых ошибок в Python

В Python большинство методов сокетного модуля генерируют исключения:

• os.OSError — его обычно генерируют функции и методы, которые устанавливают значение переменной errno. По завершении C-вызовов проверяется результат функции, исключение OSError генерируется в случае ошибки. Текст ошибки соответствует значению переменной errno.

• TimeoutError — возникает при истечении тайм-аутов на сокетах, где тайм-аут установлен. Обычно в результате ошибки некий вызов может быть, например, повторен несколько раз, прежде чем окончательно завершится с ошибкой.

• ValueError — общее исключение, возникающее, когда в метод или функцию переданы аргументы неверного типа. Обычно не требует обработки: нужно корректно передавать в функции и методы параметры, но иногда, когда параметры формируются динамически, имеет смысл обрабатывать данное исключение.

Понятно, что остальные исключения, такие как MemoryError и подобные, также могут быть сгенерированы.

Внимание! Основным исключением, которое необходимо обрабатывать при вызове функций сокетного модуля и методов объектов класса socket.socket, является OSError.

Разрешение имен может вызывать отдельный тип исключений:

• socket.gaierror — при ошибках работы getaddrinfo().

• socket.herror — это исключение могут генерировать функции gethostbyname_ex() и gethostbyaddr().

Многие библиотеки имеют свои наборы исключений. Обработку этих исключений мы затронем при рассмотрении библиотек в следующих книгах.

В целом обрабатывать сетевые исключения в Python можно следующим образом:

try:

# Сетевые вызовы.

except socket.error as e:

# Обработка сокетных исключений.

except IOError as e:

if errno.EPIPE == e.errno:

continue

else:

# Обработка других исключений.

Прочие языки

В большинстве языков используются два рассмотренных ранее варианта работы с ошибками:

• Возврат кодов ошибок. Так, например, организована часть работы с ошибками в изначальном POSIX API в C или работа с ошибками в адаптации этого API для языка Go.

• Исключения. Сокетный API может быть обернут в функции и методы, которые генерируют исключения, как в C++ или Python.

Относительно редким вариантом является установка глобальной переменной ошибки, например errno, как в C. Но в нем от этой практики отказываются и переменная уже, по сути, является не глобальной, а локальной для потока.

Нечто среднее между кодом ошибки, возвращаемым функцией, и глобальной переменной представляет собой код возврата, который используется в языках оболочки.

В Rust и некоторых других языках функции возвращают перечисление, которое содержит результат или ошибку:

pub enum Result<T, E> {

Ok(T),

Err(E),

}

Для функций и методов ввода-вывода, каковыми являются и сетевые функции, возвращается его псевдоним — тип std::io::Result, в котором ошибка специфицирована типом io::Error:

pub type Result<T> = result::Result<T, Error>;

Например, как в функции bind(), которая возвращает объект класса TcpListener в случае успеха:

pub fn bind<A: ToSocketAddrs>(addr: A) -> io::Result<TcpListener>

Особенности сетевых приложений

В различных ОС приложение может получать более подробные данные об ошибке конкретного сообщения или потока.

В главе 8 мы рассматривали несколько опций, которые позволяют это сделать. Начнем с опции SO_ERROR, которая позволяет сразу получить и сбросить ошибку сокета. Возвращаемый код ошибки такой же, как в errno. Данная опция полезна при работе с неблокирующими сокетами через функцию select(), рассматриваемую в книге 2.

Дело в том, что при асинхронном запуске connect() ожидающая на дескрипторе сокета функция select() возвращается, если соединение было успешным или неудачным.

Если соединение не удалось, требуется получить ошибку, которая и хранится в SO_ERROR. Получить ее через множество дескрипторов ошибок можно только в ОС Windows. Иначе в обоих случаях будет установлен флаг в множестве дескрипторов чтения или записи. То есть в любом случае опцию следует проверить, чтобы убедиться в успешном подключении.

Помимо локальных ошибок, в сокете может возникать ошибка, переданная удаленным абонентом по ICMP. Например, если порт, на который адресована дейтаграмма, недоступен, узел пришлет ICMP-сообщение «Port unreachable».

В дейтаграммном сокете все сгенерированные ошибки будут помещены в очередь ошибок сокета. Чтобы получить такие ошибки, на уровне IP существуют опции сокетов IP_RECVERR и IPV6_RECVERR. Это флаги, которые включают передачу сообщений из очереди ошибок.

Когда сокетный вызов завершается с ошибкой, вызов recvmsg() с установленным флагом MSG_ERRQUEUE вернет во вспомогательном сообщении типа IP_RECVERR или IP_RECVERR6 структуру sock_extended_err:

// Происхождение неизвестно.

#define SO_EE_ORIGIN_NONE 0

// Ошибка на стороне узла.

#define SO_EE_ORIGIN_LOCAL 1

// Ошибка была получена через сообщение ICMP или ICMP6.

#define SO_EE_ORIGIN_ICMP 2

#define SO_EE_ORIGIN_ICMP6 3

struct sock_extended_err

{

// Номер ошибки в очереди.

uint32_t ee_errno;

// Источник происхождения ошибки.

uint8_t ee_origin;

// ICMP-тип.

uint8_t ee_type;

// ICMP-код.

uint8_t ee_code;

// Заполнение для выравнивания.

uint8_t ee_pad;

// Дополнительная информация.

// Для ошибок EMSGSIZE содержит обнаруженный MTU.

uint32_t ee_info;

// Другие данные.

uint32_t ee_data;

// Далее могут следовать дополнительные данные.

};

Содержимое пакета, который привел к ошибке, передается в виде обычных данных в msg_iovec, а его исходный адрес — в msg_name.

Для локальных ошибок адрес не передается, что видно по значению поля cmsg_len.

Следующий макрос возвращает указатель на адрес сетевого объекта, в котором возникла ошибка:

sockaddr *SO_EE_OFFENDER(sock_extended_err *);

Если адрес неизвестен, sockaddr.sa_family будет равен AF_UNSPEC, а другие поля sockaddr не определены.

Для raw-сокетов опция позволяет передавать все полученные ICMP-ошибки приложению, в иных случаях об ошибках сообщается только в подключенных сокетах.

TCP не имеет очереди ошибок, поэтому сообщение MSG_ERRQUEUE нельзя использовать для сокетов SOCK_STREAM.

IP_RECVERR не приведет к ошибке на TCP-сокетах. Он просто бесполезен. Все ошибки будут возвращаться в errno или в значении опции SO_ERROR.

После получения ошибки следующая за ней ошибка в очереди становится ожидающей и передается процессу при следующей операции на сокете.

Сигналы

В Unix-подобных ОС общим для многих языков способом уведомления являются сигналы, часть из которых показана на рис. 25.2.

И хотя не все языки поддерживают работу с ними, в большинстве современных языков обработка сигналов реализована. В Rust для этого, например, есть модуль std::io::signal.

Сетевые приложения в плане обработки сигналов не выделяются на фоне остальных.

Внимание! Некоторые сигналы, в частности SIGPIPE, приведут к немедленному завершению приложения с ошибкой, если их не обработать или не игнорировать явно.

Такие вызовы, как send() или recv(), зачастую реализованы через write() и read(), то есть могут порождать сигналы, например, при обрыве канала. Некоторые из сигналов уже были описаны в книге.

Рис. 25.2. Сигналы в Linux

В разных системах определено разное количество сигналов разного типа. В Linux, например, 31 тип, но их может быть меньше или больше, а часть сигналов является синонимами.

Команды оболочки для работы с сигналами

Существует список из 38 сигналов, часть из которых присутствовала только в UNIX System V, а часть — в старых BSD-системах или в Solaris. Если учитывать Real Time-сигналы — SIGRT*, в некоторых ОС их количество может составлять порядка 64.

Посмотреть список сигналов используемой ОС обычно можно с помощью утилиты kill:

➭ kill -l

HUP INT QUIT ILL TRAP IOT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT

CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

Отправить сигнал также с помощью данной утилиты, передав ей имя сигнала и PID нужного процесса:

➭ kill -URG $$

В Linux и BSD-системах утилиты pkill и killall позволяют отправлять сигналы всем процессам с заданным именем.

В потомках Unix System V, таких как Solaris, IBM AIX и HP-UX HP, команда killall убивает все процессы, которые могут быть уничтожены конкретным пользователем, то есть завершает его работу, а при запуске от root завершает работу системы.

Утилита pkill дает возможность отправить сигнал всем процессам, имя которых содержит заданную строку. Например, команда pkill -9 java принудительно завершит все java-приложения.

pkill является частью пакета procps, который также предоставляет другие утилиты файловой системы /proc, такие как pgrep, ps, top, free, uptime и др. Также pkill имеет удобную опцию -t — отправить сигнал процессам только на определенном терминале.

Утилита killall в Linux предоставляется пакетом psmisc и отличается от pkill тем, что по умолчанию имя процесса в ней должно точно соответствовать заданному. В ней, однако, поддерживается сопоставление имен процессов с регулярными выражениями с помощью опции -r или --regexp.

Также команда killall имеет опции для сопоставления процессов по возрасту -o, --older-than и -y, --younger-than.

В этой главе мы рассматриваем только сигналы, которые касаются ошибок, специфичных для сетевых приложений и сетевого ввода-вывода.

То есть мы не рассматриваем подробно сигналы SIGBUS или SIGIO. Возникновение первого не имеет отношения к вводу-выводу, а второй не говорит о том, что произошла ошибка.

В целом внимания заслуживают следующие сигналы:

• SIGALRM — сигнал таймера, который может быть использован для реализации Heartbeat, о котором рассказано далее.

• SIGCHLD или SIGCLD — родительский процесс получит этот сигнал, если дочерний процесс был завершен или остановлен. По умолчанию игнорируется.

• SIGHUP — дочерний процесс получит данный сигнал, если завершится родительский процесс или отключится управляющий терминал.

• SIGIO или SIGPOLL — данный сигнал приходит, только если он был явно включен. Используется для ввода-вывода, управляемого сигналами. Его приход говорит о возможности прочитать данные из сокета или записать данные в сокет. Приходом этого сигнала управляют уже рассмотренные опции SO_RCVLOWAT и SO_SNDLOWAT. В старых ОС, например Unix System V, он называется SIGPOLL, в новых обычно SIGIO.

• SIGPIPE — важный сигнал обрыва канала, говорящий о том, что запись продолжается в канал, у которого нет читателей. Если этот сигнал поступил, но не был обработан, приложение завершит работу.

• SIGURG — требование срочной обработки OOB-данных, поступивших в сокет. Этот сигнал мы рассматривали, когда описывали работу с OOB. По умолчанию он игнорируется.

Обработка сигналов

Каждый процесс обрабатывает только один сигнал каждого типа одновременно. Если до полной обработки первого сигнала придут другие сигналы, они будут отброшены.

Можно разрешить прерывание работы обработчика. Но обработчик сигналов может использовать другой аппаратный стек для приема вызовов. Поскольку этот стек по умолчанию не очень глубокий, есть вероятность его переполнения.

Поэтому в обработчике сигнала лучше выполнять как можно меньше действий и при этом стараться не вызывать функции с побочными эффектами.

Например, в обработчиках сигналов сложно выполнять ввод-вывод. Это потенциально может привести к потере сигналов, а блокировки ввода-вывода — к зависанию программы и другим негативным последствиям.

Даже выделение памяти в обработчиках сигналов выполнять нельзя. А глобальные переменные, которые, скорее всего, придется использовать в обработчике, должны иметь квалификатор volatile.

Внимание! Кроме сказанного выше, сигналы прерывают поток процесса и выполнение обработчика дороже, чем стандартные системные вызовы, такие как select() и poll(), поэтому использовать сигналы нежелательно.

Обработчик может принять сигнал и установить переменную или поместить сигнал в очередь. Дальнейшая обработка сигнала из очереди будет выполняться уже вне обработчика.

У сигналов есть важный  недостаток. Их наличие говорит лишь о том, что произошло событие, однако дополнительной информации об этом событии сигналы не предоставляют. Поэтому решить, как обрабатывать поступивший сигнал, зачастую сложно.

Обработка сигналов в POSIX API предполагает использование функций signal() и sigaction() для установки обработчиков. Как уже было показано на примере Rust, для этого применяются различные языковые средства.

В Python это модуль signal307, предоставляющий API, не сильно отличающийся от POSIX:

import signal, os

# Обработчик сигналов.

def handler(signum, frame):

# Имя полученного сигнала.

signame = signal.Signals(signum).name

print(f'Signal handler called with signal {signame} ({signum})')

raise OSError("Couldn't open device!")

# Установить обработчик SIGALRM.

signal.signal(signal.SIGALRM, handler)

# Через 5 секунд будет сгенерирован SIGALRM.

signal.alarm(5)

# Вызов open() может блокироваться бесконечно.

fd = os.open('/dev/ttyS0', os.O_RDWR)

# Отключить уведомления.

signal.alarm(0)

Мы не будем подробно рассматривать данный модуль, так как он не относится к разработке сетевых приложений напрямую.

В языках оболочки, таких как Bash, обработка сигналов выполняется путем вызова команды trap, которой передается обработчик и номер сигнала или его символическое обозначение:

trap "echo 'Signal'" SIGINT

Рассмотрим подробнее сигналы, которые могут быть важны для сетевых приложений.

Внимание! Ниже используются базовые функции для обработки сигналов. Например, при работе с потоками лучше использовать функцию pthread_sigmask() для блокирования сигнала, а наличие уже пришедшего сигнала проверять функцией sigpending(). Для процесса же лучше использовать sigprocmask(). Все эти нюансы далее не учитываются.

SIGCHLD

Сигнал SIGCHLD уведомляет родительский процесс о том, что дочерний процесс завершился.

В этом случае ядро сохраняет контекст дочернего процесса, чтобы родитель мог узнать результат выполнения, — обычно получить код возврата. Если сигнал проигнорирован, контексты накапливаются, а завершенные процессы, код возврата которых не был прочитан, отображаются в списке процессов как «зомби».

Программы в обработчике этого сигнала обычно используют системный вызов wait().

В обработчике сигналов лучше не блокироваться на длительное время, так что желательно использовать любую wait-функцию, в которую можно передать флаг WNOHANG: wait3(), waitpid(), waitid() и т.д. Этот флаг предотвращает блокировку: функции вернут управление, если ни один дочерний процесс не завершился.

Для использования wait-функций необходимо включить заголовочные файлы sys/wait.h и sys/resource.h.

Рассмотрим код обработчика сигнала SIGCHLD:

void proc_exit(int signal)

{

std::cout

<< "Signal: "

<< signal << " (" << strsignal(signal) << ")"

<< std::endl;

while (true)

{

int wstat;

// -1 — ожидать любой дочерний процесс.

// WNOHANG — немедленно вернуть результат, не ожидая завершения

// процессов.

pid_t pid = waitpid(-1, &wstat, WNOHANG);

switch (pid)

{

// При ошибке вернет -1. Ошибкой является и отсутствие дочерних

// процессов.

case -1:

perror("wait()");

return;

// Если указан флаг WNOHANG, wait-функция завершится и вернет 0,

// когда ни один дочерний процесс не изменил состояние.

case 0:

std::cout

<< "No waitable children"

<< std::endl;

return;

// Код возврата потомка.

default:

std::cout

<< "Child returns code: "

<< wstat

<< std::endl;

}

}

}

Особенностью данного обработчика является то, что wait-функция вызывается в цикле, что позволяет учесть даже процессы, завершившиеся в процессе выполнения обработчика. Ошибки в коде выводятся через perror(), чтобы не загромождать код.

В рабочем коде C++ необходимо грамотно оформить вывод ошибок и помнить, что в обработчике нежелательно использовать функции ввода-вывода.

Функция main() просто устанавливает обработчик сигнала и порождает новый процесс:

int main ()

{

if (SIG_ERR == signal(SIGCHLD, &proc_exit))

{

perror("signal()");

}

switch (fork())

{

case -1:

perror("fork()");

return EXIT_FAILURE;

case 0:

std::cout

<< "Child exited..."

<< std::endl;

return EXIT_SUCCESS;

default:

getchar();

}

}

Посмотрим на результат:

➭ build/bin/b01-ch25-sigchld_handler

Child exited...

Signal: 17 (Child exited)

Child returns code: 0

wait(): No child processes

Видим, что был получен сигнал и один из потомков завершился с нулевым кодом возврата. Повторный вызов функции вернул ошибку, так как потомков больше не осталось. Вероятно, эту ситуацию лучше проигнорировать: не выводить ошибку, если errno равна ECHILD.

SIGHUP

Если родительский процесс завершается, дочерний получает сигнал SIGHUP. По умолчанию процесс, получивший это сообщение, будет завершен. Характерный случай — завершение процесса оболочки, когда пользователь выходит из системы.

В Linux существует команда nohup, которая запускает дочерний процесс, а сама игнорирует SIGHUP от родителя. Она позволяет запускать в фоне процессы, которые будут работать даже после того, как пользователь выйдет из системы, завершив оболочку.

Такие программы, как tmux или screen, также перехватывают SIGHUP.

Многие серверы реализуются как демоны, и для них характерны некоторые особенности в обработке данного сигнала:

• Демон работает в фоне или запускает фоновый процесс. Родительский процесс завершает работу, поэтому SIGHUP требуется перехватывать.

• Обычной практикой является перезапуск демона либо чтение им настроек с диска, когда он получает SIGHUP: текущий запущенный процесс должен завершиться, после чего будет запущен его новый экземпляр, который перечитает настройки.

SIGPIPE

Это один из самых важных сигналов. Он говорит о том, что при отправке произошла ошибка канала. Например, узел назначения закрывает соединение.

Когда получен этот сигнал, необходимо выяснить, запись в какой из сокетов его сгенерировала, и закрыть этот сокет, потому что канала больше нет и никакие действия с ним выполнить нельзя.

Если реализуется клиент, целесообразно его завершить, если, конечно, он не управляется через интерактивный пользовательский интерфейс. Если же клиент интерактивный, можно попробовать через некоторое время заново установить связь с сервером. Это время требуется на перезапуск сервера, который мог завершиться, перестройку маршрутизации в сети и т.п. Зачастую после этих действий необходимо повторить попытку соединения несколько раз.

Чтобы избежать генерации данного сигнала, следует использовать опцию MSG_NOSIGNAL при вызове send(), либо проигнорировать его:

#include <csignal>

...

std::signal(SIGPIPE, SIG_IGN);

В этом случае завершится только функция send(), которая вернет –1, а errno будет установлен в EPIPE. Это говорит о том, что работу с данным сокетом необходимо прекратить.

На практике игнорирование сигналов обычно выполняется следующим образом:

void HttpServer::ignore_sigpipe()

{

sigset_t mask;

// Обнулить маску.

sigemptyset(&mask);

// Добавить SIGPIPE в маску.

sigaddset(&mask, SIGPIPE);

// Блокировать сигналы в маске.

if (pthread_sigmask(SIG_BLOCK, &msk, nullptr) != 0)

throw std::runtime_error("pthread_sigmask() call failed");

}

Подробнее о SIGPIPE вы можете почитать в man 7 pipe.

Кроме обработки сигнала и поведения, заданного по умолчанию, существует несколько вариантов поведения:

• Игнорирование сигнала.

• Опция SA_RESTART, которую может принимать функция sigaction(). Она позволяет выполнять перезапуск вызовов, таких как send() и recv(), если они были прерваны сигналом.

Почему бы всегда не перезапускать и не игнорировать сигнал?

Ответ достаточно очевиден: приложению может быть необходимо сразу же обрабатывать ситуацию обрыва канала. Потому что в общем случае нормальное приложение должно быть «отзывчивым».

Сигнал может быть проигнорирован лишь в одном случае: приложение выполняет критически важную задачу и не должно быть завершено. В остальных случаях игнорировать сигналы нельзя, так как даже если пользователь завершит приложение, такое поведение лишь осложнит его работу.

То есть приложение должно среагировать, если пользователь собирается выполнить какие-либо действия.

Чтобы данные не терялись и не повреждались, на сигнал устанавливается обработчик, который записывает промежуточное состояние. Так поступают некоторые torrent-библиотеки, которые будут рассмотрены в книге 3.

Проблемы с дескрипторами

Системный вызов fork() создает копию текущего процесса, в том числе таблицу дескрипторов. То есть счетчики количества открытий файлов увеличиваются на количество ссылающихся на них файловых дескрипторов. А дочерний процесс, который унаследует дескрипторы родительского, может их даже не использовать.

Часто такая ситуация возникает при запуске новой программы: сначала вызывается fork(), затем в новом процессе вызывается exec(), который загружает программу в память и запускает ее, то есть заменяет образ текущего процесса.

Если же новый процесс запускается с привилегиями более низкими, чем запускающий, некоторые дескрипторы просто необходимо закрывать, чтобы избежать проблем с безопасностью. Их можно закрывать перед вызовом exec(). Но существует удобная библиотечная функция system(), запускающая приложение, и при ее использовании так закрыть дескрипторы уже не получится.

Сама функция не может закрывать дескрипторы: вызывающее приложение иногда переназначает их, так что непонятно, какие из дескрипторов нужно оставить, а какие закрыть.

Чаще всего переназначать требуется дескрипторы стандартного ввода-вывода, которые будут унаследованы запускаемой программой. Это нужно для того, чтобы прочитать вывод дочернего процесса и отправить ему ввод, который он читает с консоли.

Чтобы решить проблему, существует механизм явного автоматического закрытия дескрипторов. Он представляет собой флаг дескриптора CLOEXEC и обычно устанавливается с помощью функции fcntl():

• Через флаг FD_CLOEXEC команды F_SETFD.

• При дублировании файлового дескриптора через команду F_DUPFD_CLOEXEC. Эта команда аналогична F_DUPFD кроме того, что она сразу устанавливает флаг CLOEXEC на продублированный дескриптор.

Все дескрипторы с флагом CLOEXEC будут закрыты при любом вызове exec() атомарно и автоматически.

В Python данный флаг устанавливается так:

import fcntl

def set_close_exec(fd):

"""

Хелпер, добавляющий флаг CLOEXEC к файловому дескриптору.

:param fd: int

"""

flags = fcntl.fcntl(fd, fcntl.F_GETFD)

fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)

В C и C++ все точно так же. В ОС Windows данная проблема тоже существует, но за неимением fcntl() для ее решения следует использовать функцию SetHandleInformation() с флагом HANDLE_FLAG_INHERIT. Подробнее она была описана в главе 18.

В некоторых многопоточных приложениях использование отдельной операции fcntl() для установки флага может привести к гонке: один поток открывает файловый дескриптор и пытается установить флаг, а другой уже выполняет fork() и exec().

При использовании сокетов гонка также может происходить между получением нового сокета от accept() и последующей установкой флага CLOEXEC.

Чтобы избежать гонки, в Linux в параметр типа при вызове функции socket() необходимо добавить флаг SOCK_CLOEXEC:

int sock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);

Для сокетов, возвращаемых из accept(), предусмотрена функция accept4(), описанная в главе 5. Она предоставляет возможность установить новому сокету переданные флаги, в том числе SOCK_CLOEXEC. Установка переданных флагов будет сделана атомарно, и состояния гонки не возникнет.

К сожалению, описанные опция и функция существуют только в Linux.

В функции open() и подобных также можно установить флаг O_CLOEXEC с целью избежания гонки, но уже для любого дескриптора. В WinAPI для этой цели есть плохо документированный флаг O_NOINHERIT, _O_NOINHERIT или _FNOINHERIT. В Python этот флаг определен, как os.O_NOINHERIT.

Повышение стабильности работы

Перечислим мероприятия, которые помогут улучшить стабильность программы, не допустить новых ошибок и выявить старые:

• Автоматическое тестирование, в частности юнит-тесты на каждую функцию и метод. Оно отнимает много времени, но оправдывает себя. Тесты позволяют соблюдать «контракт» и особенно помогают, когда вносятся изменения, а тесты работают как регрессионные.

• Статические проверки компилятором. Не передавайте в метод параметры через map<string, string>, как делают некоторые. Используйте строгую типизацию, константные спецификаторы и проверки на уровне компиляции, чтобы избежать многих ошибок. Этот же пункт включает широкое использование контрактов, которые можно ожидать в 26-м Стандарте C++308.

• Динамический анализ кода. Производится выполнение программы, по результатам которого анализатор выдает отчет.

• Статический анализ кода. Расширенные линтеры или автоматические анализаторы кода позволяют избежать многих ошибок еще до запуска программы.

Оценка метрик кода. Чем проще код, тем проще его понять и заметить ошибку. Такие программы, как cccc309, SourceMonitor310 и подобные, могут считать метрики. Например, цикломатическую сложность по Маккейбу: чем она выше, тем сложнее понимать, что делает программа. Если сложность кода выше 10, понимание очень затруднено.

• Fuzzing-тестирование. Работающей программе на вход подаются специально формируемые данные и проверяется, как она себя ведет, все ли ветки кода пройдены.

• Сторонний ручной анализ кода. Обычно проводится на этапе ревью кода перед вливанием его в основное кодовое дерево. Чтобы упростить анализ кода и оптимизировать работу над ним, у многих компаний существуют руководства по стилю. Лучше соблюдать плохой единый стиль, чем не иметь никакого. В некоторых случаях весь новый код дополнительно анализируют специалисты уполномоченных лабораторий.

• Доказательство корректности. Для подтверждения абсолютной корректности в критичных системах можно использовать системы автоматического вывода теорем, такие как Agda311. Как правило, такое доказательство проводят для участков системы: выполнить его для всего кода часто невозможно или нерентабельно.

• Мониторинг и телеметрия работающей системы.

• Налаженный процесс CI/CD, в котором применяются все описанные выше методы.

• Запуск анализа на ранних этапах, или так называемый сдвиг влево по времени. Анализаторы, такие как cpplint, clang-tidy, pylint, запускаются еще на машине разработчика автоматически перед коммитом. Это не исключает проверок на сборочном конвейере, но позволяет ускорить разработку.

Конечно, этот список неполон. В разных отраслях промышленности, автомобильной и авиационной, существуют стандарты, которые регламентируют способы проверки и повышения надежности. Периодически выходят руководства по безопасному программированию. Например, Software Safety Guide Book от NASA312, основанное на опыте проектирования космической техники.

Принципиальные требования для сетевых и «обычных» приложений одинаковы:

• Наличие системы, которая не позволяет коду низкого качества уходить в релиз.

• Наличие методики написания кода, обязательной для соблюдения разработчиками.

Кажется, что это очень сложно реализовывать и потраченное время лучше использовать на непосредственно разработку приложения, но, как правило, затраты себя окупают. Причем тем быстрее, чем больше разработчиков и чем крупнее разрабатывающая продукт компания.

Более того, для внедрения перечисленных выше практик компании нанимают DevOps-специалистов, то есть инженеров, отвечающих за операции разработки. Для внедрения конвейера операций существуют промышленные решения, например Jenkins, TFS и подобные. А для крупных систем такие решения вследствие глубокой кастомизации приводят к созданию полноценного инфраструктурного продукта, который разрабатывается и поддерживается отделом DevOps.

Heartbeat

Стоит еще раз повторить, что в случае сетевых приложений канал нельзя считать надежным. Он может быть оборван.

Если приложения работают поверх какого-либо протокола, организующего соединение, например TCP, обрыв канала может быть обнаружен не сразу:

• Если приложение ожидает данные, канал может отсутствовать несколько минут, и это не будет замечено.

• Если приложение отправляет данные, обрыв канала будет обнаружен при отправке, в результате чего приложение получит сигнал SIGPIPE.

В некоторых случаях диагностика канала должна быть постоянной. Это обеспечивает техника Heartbeat — периодическая отправка проверочных сообщений по каналу.

Heartbeat можно совместить с техникой сторожевого, или watchdog, таймера.

Пришедшее сообщение должно сбрасывать таймер.

В случае, если приложение неработоспособно, по таймеру оно будет перезапущено через некоторое время после того, как прекратилось поступление сообщений.

В различных встраиваемых устройствах часто сторожевой таймер реализуется аппаратно, и если он не сброшен программой, обычно вызывает перезагрузку системы, тем самым предотвращая ее зависание.

Надо понимать, что срабатывание таймера — нештатная ситуация, в которой необходимо разбираться.

Они могут отправляться через регулярные промежутки времени, прерывая отправку данных, или только если канал долго не использовался. В этом случае отправка данных будет прерывать таймер heartbeat.

На сообщение может требоваться явный ответ, как в случае ping, или может быть использовано ожидание уведомления о работоспособности.

Данная техника широко распространена в кластерах. Там она используется не только для того, чтобы диагностировать неисправный канал, но и для того, чтобы определить, что узел неисправен.

Если несколько проверочных сообщений вызывают сбой, ответ или сообщение не приходят в течение указанного промежутка времени, приложение может выполнить следующие действия:

• Сигнализировать о сбое и завершить работу.

• Перейти на резервный канал.

• В новом соединении — уменьшить размер порций данных, которое оно передает, зная, что надежность сети снижена.

Зачастую heartbeat вообще не требуется реализовывать, из-за того что он может предоставляться транспортным протоколом. Например, в SCTP существует отдельный блок heartbeat.

Но если требуется сделать обмен данными на прикладном уровне относительно независимым от нижележащего протокола и при этом надежным, возможно, реализация heartbeat все же потребуется.

Литература по отладке

Отладка — это обширная и сложная область, которая может потребовать дополнительного изучения, чтобы отлаживать приложения эффективно и быстро искать ошибки.

Конечно, данный навык приходит с опытом, но может быть нелишним изучить соответствующую литературу. При этом литературу по отладке именно сетевых приложений найти вряд ли получится, но книги по «общей» отладке приложений также полезны:

• Книга David J. Agans «Debugging»313 (2002) содержит описание большей части методов отладки, а также дает понимание того, что это за процесс и как он выполняется.

• Книга Robert Charles Metzger «Debugging by Thinking»314 (2003) показывает разные подходы к отладке.

• Некоторые способы отладки также описаны в книге Diomidis Spinellis «Effective Debugging: 66 Specific Ways to Debug Software and Systems»315 (2016).

Возможно, стоит поискать обзоры различной литературы. Например, в работе «Debugging: A review of the literature from an educational perspective»316 (2008) не только перечислены ресурсы, которые можно почитать, и исследования, проводимые в области изучения отладки, но и рассказано, откуда появляются баги, как специалисты производят отладку, сколько на нее тратится времени и т.п. Кроме того, в ней даются советы по улучшению навыков отладки.

Кому-то будет проще смотреть видеоролики, множество которых доступно на YouTube. Достаточно поискать их по запросу «Debugging» или «Debugging techniques».

Резюме

На примере поиска ошибки в UDP-клиенте в этой главе мы выяснили, что в первую очередь необходимо проверить данные, которые отправляются по сети. В этом может помочь сниффер либо отладочный прокси.

Обнаружение ошибок в приложении обычно начинается с локализации места их возникновения, а помогают в этом средства журналирования и встроенной диагностики. Также могут быть использованы встроенные средства диагностики ОС.

Если сразу локализовать ошибку не удалось, используется отладка и средства трассировки. Некоторые библиотеки могут заменяться на отладочные версии — более медленные, но с расширенной диагностикой.

В поиске ошибок приложения часто помогает ужесточение проверок компилятора, санитайзеры, статистический анализ — это автоматические проверки и выполняются они быстрее «поиска вручную».

Иногда требуется не исправлять «ошибки» приложения, а обратиться к сетевому администратору и начать поиск ошибок в сети.

Хотя для поиска ошибок существует множество мощных инструментов, эта задача все равно остается достаточно сложной. Поэтому при правильной разработке сетевого приложения оно должно само уведомлять о проблемах. Для этого необходимо проверять результат каждой операции, вызываемой в ходе работы приложения, а в случае возникновения ошибок следует предусмотреть их корректное отображение и последующую обработку и либо завершить работу приложения, либо выполнить обработку ошибки другим методом. Желательно при этом записывать сведения, позволяющие выявить ошибку, в журнал работы приложения.

Многие языки программирования генерируют исключения при ошибках — эти исключения должны быть корректно обработаны в программе.

В приложениях для Unix-подобных ОС, кроме ошибок вызовов, обычным способом уведомления являются сигналы, поэтому требуется уделять дополнительное внимание их обработке.

Часто используемые функции, такие как send() или recv(), могут генерировать сигналы в различных случаях, например при обрыве связи. И если этот сигнал не обработать, приложение завершится.

Еще одной распространенной ошибкой является утечка дескрипторов, что приводит к исчерпанию ресурсов и завершению приложения. Эту проблему тоже необходимо отслеживать и по возможности использовать флаги, подобные SOCK_CLOEXEC, при создании новых системных объектов, таких как сокеты.

Кроме правильной обработки результатов вызова функций, сигналов, исключений, существует еще несколько мер для повышения стабильности работы приложений. Например, heartbeat, при использовании которого приложение регулярно сообщает о том, что оно работоспособно.

Мы также привели несколько ссылок на литературу по отладке, из которой можно получить более подробные сведения о поиске ошибок.

В общем случае приложения должны корректно работать в разных условиях, а их код — быть легко изменяемым и понятным читателю.

Вопросы и задания

1. Как определить, что ошибка находится именно в клиентском приложении?

2. Какие шаги следует предпринять для локализации проблемы в сетевом приложении?

3. С чего нужно начинать отладку, если выяснилось, что отправляемое сообщение некорректно?

4. Какие инструменты и средства помогают локализовать ошибки в сетевых приложениях помимо отладки?

5. Как называется один из самых распространенных инструментов динамического анализа в Linux и других Unix-подобных системах? Каковы его возможности?

6. Какие вы знаете инструменты динамического анализа в ОС Windows?

7. Для чего иногда требуется подменять функции, вызываемые из библиотек? Какие функции обычно подменяются?

8. Какие проверки и опции лучше включить при компиляции приложения?

9. Для чего нужны статические анализаторы? Почему их задачи не выполняет компилятор?

10. На каком уровне журналирования желательно записывать подключение к серверу? А неожиданный разрыв связи?

11. Какое правило должно соблюдаться при передаче аргументов вызову логгера?

12. Какие существуют библиотеки журналирования?

13. Какие распространенные ошибки допускают разработчики сетевых приложений?

14. Почему важно проверять результат каждой операции в сетевом приложении и что следует делать при возникновении ошибок?

15. Почему нельзя читать строку ошибки из буфера, переданного GNU-версии функции strerror_r()? Почему в POSIX-версии, наоборот, требуется работать только с буфером?

16. Почему в ОС Windows для получения кода сетевых ошибок нежелательно использовать функцию GetLastError()? Какую функцию лучше использовать вместо нее?

17. Как получить строку ошибки для блокирующего вызова getaddrinfo()? А для неблокирующего?

18. Какой класс исключений лучше использовать в C++ для извещения об ошибках сетевых функций?

19. Исключения какого класса обычно генерируют сетевые функции в Python и методы класса socket.socket?

20. Исключения какого класса генерируют в Python функции разрешения имен?

21. За что отвечает опция сокета SO_ERROR? На каких сокетах она будет работать?

22. Какие сигналы нужно обрабатывать в сетевых приложениях и зачем?

23. Что случится, если не закрывать дескрипторы в новых процессах?

24. В каких операционных системах поддерживается флаг SOCK_CLOEXEC и что он делает? Какая замена существует для него в ОС Windows?

25. Что может улучшить стабильность работы приложения?

26. Что такое heartbeat и когда он полезен?

27. Где можно найти сведения по отладке?

28. Какие еще проблемы и ошибки вы заметили в примере некорректного UDP клиента? Исправьте клиент так, чтобы код был корректным. Добавьте обработку сигналов.

29. Перепишите traceroute, разработанный в предыдущей главе, на использование UDP-зондов. Используйте опцию IP_RECVERR для получения ошибок ICMP.

292 https://drmemory.org/

293 https://dmalloc.com/

294 https://pvs-studio.ru/ru/blog/posts/0614/

295 https://www.ispras.ru/technologies/svace/

296 https://en.wikipedia.org/wiki/General_Data_Protection_Regulation

297 https://github.com/fffaraz/awesome-cpp#logging

298 https://www.boost.org/doc/libs/1_73_0/libs/log/doc/html/index.html

299 https://github.com/google/glog

300 https://github.com/gabime/spdlog

301 https://baical.net/p7.html

302 https://github.com/SergiusTheBest/plog

303 https://en.cppreference.com/w/cpp/error/errno_macros

304 https://www.club.cc.cmu.edu/~cmccabe/blog_strerror.html

305 https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-wsagetlasterror

306 https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-formatmessage

307 https://docs.python.org/3/library/signal.html

308 https://habr.com/ru/articles/443766/

309 https://github.com/sarnold/cccc

310 https://github.com/SourceMonitor

311 https://ru.wikipedia.org/wiki/Agda

312 https://standards.nasa.gov/standard/nasa/nasa-gb-871913

313 https://www.oreilly.com/library/view/debugging/9780814474570/

314 https://www.oreilly.com/library/view/debugging-by-thinking/9781555583071/

315 https://www.oreilly.com/library/view/effective-debugging-66/9780134394909/

316 https://www.researchgate.net/publication/37146936_Debugging_A_review_of_the_literature_from_an_educational_perspective

Глоссарий

• ABI — Application Binary Interface, двоичный интерфейс приложений. Соглашения, обеспечивающие взаимодействие программ с операционной системой или другими низкоуровневыми программами на уровне скомпилированного кода, что позволяет программам работать на разных устройствах при условии совместимости ABI. В отличие от API, который ориентирован на совместимость исходного кода, ABI позволяет программам работать без необходимости повторной компиляции.

• ACK-paced-протокол — протокол, темп или скорость работы которого задается приходом ACK. То есть ACK заставляет протокол дальше работать, и это наиболее частая, типичная ситуация.

• ATM — Asynchronous Transfer Mode. Сетевой метод, который предусматривает дробление информации на фиксированные по размеру части. В отличие от таких систем, как Ethernet и интернет, где размеры данных могут изменяться, ATM использует унифицированные ячейки для передачи. Этот подход лежит в основе работы сетей SONET и ISDN, обеспечивая необходимую синхронность данных.

• API — Application Programming Interface. Программный интерфейс приложения. Описание способов, классов, процедур, функций, структур и констант, используя которые одна компьютерная программа может взаимодействовать с другой программой.

• ARM — процессоры с архитектурой компании Advanced RISC Machines.

• ASN.1 — Abstract Syntax Notation One. Стандарт сериализации данных. Предназначен для формализации данных, их кодирования и обмена между системами. ASN.1 не привязан к определенной аппаратуре. Стандартизован ISO и ITU-T под кодовым обозначением X.409:1984. Однако его значимость привела к выделению в отдельный стандарт X.208 в 1988 году. С 1995 года используется обновленная версия, зафиксированная в документе X.680.

• AVR32 — семейство 32-битных микроконтроллеров фирмы Atmel.

• BGP — Border Gateway Protocol. Протокол граничных шлюзов. Позволяет выполнять маршрутизацию в интернете между автономными системами.

• С — компилируемый статически типизированный язык программирования общего назначения. Изначально был разработан для реализации операционной системы UNIX, но впоследствии был перенесен на множество других платформ.

• C++ — компилируемый, статически типизированный язык программирования общего назначения. Поддерживает такие парадигмы программирования, как процедурное программирование, объектно-ориентированное программирование, обобщенное программирование.

• CAPTCHA — Completely Automated Public Turing test to tell Computers and Humans Apart, технология проверки, позволяющая устанавливать, кто взаимодействует с сервисом: человек или программа. Задача CAPTCHA — обеспечить безопасность веб-ресурсов, предотвращая автоматические атаки и регистрацию большого количества аккаунтов ботов.

• Captive portal — страница, на которую перенаправляется браузер подключившегося к сети пользователя, чтобы выполнить некоторые действия для получения доступа к сетевым ресурсам или в интернет, например ввод логина и пароля. Обычно используется для аутентификации абонента или рекламы.

• CI/CD — Continuous Integration/Continuous Delivery — непрерывная интеграция/непрерывная доставка — либо Continuous Deployment — непрерывное развертывание — как следующий шаг. DevOps-практика и методология разработки, при которых в код часто вносятся небольшие изменения, затем объединяемые с основной веткой разработки. Перед слиянием и после него производится автоматическая сборка кода и его тестирование. Из кода, собранного в основной ветке, автоматически создается артефакт, например пакет для установки. Если реализовано Continuous Deployment, пакет также автоматически развертывается на целевых системах, возможно, проходя дополнительное тестирование перед развертыванием.

• CIDR — Classless Inter-Domain Routing, современная система распределения IP-адресов. Она отходит от старой системы классов и вводит использование маски подсети, что позволяет более эффективно управлять ограниченным количеством адресов, предоставляя возможность настроить размер подсетей под индивидуальные потребности.

• CPU — central processing unit. Дословно — центральное обрабатывающее устройство, часто просто процессор.

• DECNet — сеть, созданная компанией Digital Equipment Corporation для соединения двух мини-компьютеров PDP-11, что заложило основы для сетевых архитектур «точка-точка». К 1980-м DEC выдвинулась в лидеры сетевых технологий. Изначальная концепция сети включала в себя три уровня и впоследствии, в 1982 году, эволюционировала в семиуровневую, став совместимой с моделью OSI.

• DLP-система — Data Leak Prevention System, система предотвращения утечек. Работает посредством глубокого анализа пакетов.

• DNS — Domain Name System, система доменных имен. Распределенная система для получения информации о доменах. Часто применяется для преобразования доменных имен в IP-адреса.

• DoD model — Department of Defence Model, модель Министерства обороны США.

• DoH — DNS over HTTPS. Протокол для получения адреса хоста по имени через HTTPS-запрос к серверу имен. Полезен для обхода DLP, например провайдеров.

• DoS-атака — Denial of Service, атака типа «отказ в обслуживании», при которой интенсивная нагрузка на систему создается большим количеством запросов. Ее цель — вызвать сбои в работе сервисов.

• DDoS-атака — Distributed Denial of Service, DoS-атака, производимая большим количеством распределенных атакующих субъектов.

• DPU — Data Processing Unit. Многофункциональные устройства, объединяющие возможности стандартных процессоров с сетевым оборудованием. Они высвобождают центральный процессор от сложных задач, связанных с управлением сетью, шифрованием, фильтрацией трафика и обработкой протоколов. Эти процессоры особенно актуальны для облачных сервисов, так как позволяют основным серверным мощностям фокусироваться на предоставлении ресурсов клиентам.

• eBPF — Extended Berkeley Packet Filter. Инструмент, позволяющий выполнять пользовательские программы непосредственно в ядре Linux, обеспечивая при этом высокую степень безопасности и изоляции. Преимущественно используется для анализа сетевого трафика, позволяя тонко настраивать обработку пакетов и собирать статистику работы системы без значительного влияния на производительность.

• EGP — Exterior Gateway Protocol. Старый маршрутизационный протокол для связи сетей. Появился в начале 1980-х и позволял роутерам разных сетевых зон общаться между собой. С течением времени EGP уступил место более современному BGP.

• ERP — Enterprise Resource Planning. Система управления ресурсами предприятия.

• FIB — Forwarding Information Base. База данных, используемая для маршрутизации. Играет важную роль в мостах и маршрутизаторах, помогая определить, куда именно направить входящие данные. Представляет собой список, связывающий MAC-адреса с конкретными портами устройств. В этом механизме заключается отличие коммутаторов от простых концентраторов. Для эффективности и быстроты работы таблицы часто применяется специализированная память CAM, поэтому FIB иногда называют CAM-таблицей.

• FTP — File Transfer Protocol, протокол передачи файлов.

• GDPR — General Data Protection Regulation. Ключевой документ Евросоюза, обеспечивающий более строгую и единообразную защиту данных граждан в границах ЕС и регулирующий процесс передачи личной информации за пределы ЕС. Принят тремя главными институтами ЕС: Европейским парламентом, Советом ЕС и Европейской комиссией.

• GNS — Global Name System. Сервис, благодаря которому приложения-серверы могут объявлять свои службы, а клиенты — находить и использовать их через QNET-сеть. Это позволяет клиентам не знать деталей расположения или поставщика службы.

• GNS — GNU Name System. Децентрализованная система управления доменами, которая защищена от вмешательства и блокировок. Она направлена на повышение уровня анонимности пользователей и служит альтернативой DNS.

• GNU — GNU’s not Unix. Некоммерческий проект Ричарда Столлмана, целью которого было создание полноценной операционной системы, аналогичной UNIX, на условиях свободного распространения и модификации исходного кода. В рамках проекта была создана большая часть необходимых элементов ОС UNIX, таких как отладчик, текстовый редактор, компилятор GCC, за исключением ядра, которое не было доработано.

• GSS-API — API для доступа к сервисам безопасности. Приложения, построенные на базе GSS-API, взаимодействуют с использованием токенов безопасности, скрывающих детали реализации низкоуровневого взаимодействия от верхнего уровня приложений. Токены могут безопасно передаваться по незащищенным сетям.

• GUID — Globally Unique IDentifier. Глобальный статистически уникальный идентификатор. Обычно — число 128 бит, представленное как структура из нескольких числовых блоков.

• Happy Eyeballs — алгоритм оптимизации загрузки ресурсов из интернета. Обеспечивает повышение скорости загрузки за счет уменьшения задержек, вызванных проблемами с IPv6. Основан на том, что DNS-запрос отправляется сразу и по IPv4, и по IPv6. Алгоритм повышает UX, отсюда его название. Описан в документах RFC 6555 «Happy Eyeballs: Success with Dual-Stack Hosts» и RFC 8305 «Happy Eyeballs Version 2: Better Connectivity Using Concurrency».

• HMI — Human-Machine Interface. Элементы устройств, обеспечивающих взаимодействие пользователя с техникой. Состоят из оборудования и программного обеспечения, которое трансформирует команды человека в управляющие сигналы для аппаратуры и ответ аппаратуры в понятные человеку данные.

• HTTPS — HyperText Transfer Protocol Secure. Безопасный протокол для передачи гипертекста, HTTP поверх TLS.

• IANA — Internet Assigned Numbers Authority. Уполномоченная организация по распределению нумерации в интернете. Управляет пространствами IP-адресов доменов верхнего уровня.

• ICMP — Internet Control Messaging Protocol. Протокол межсетевых управляющих сообщений.

• IDRP — Interdomain Routing Protocol. Стандарт OSI, протокол на основе BGP для передачи данных между различными доменами маршрутизации. Интегрирован с CLNP, ES-IS, IS-IS для эффективной работы.

• IDS — Intrusion Detection System. Система для обнаружения несанкционированных попыток входа или управления компьютерными системами, часто через интернет. Служит в качестве дополнительного барьера для усиления безопасности информационных систем узлов и сетей предприятий.

• IEEE — Institute of Electrical and Electronics Engineers, Институт инженеров электротехники и электроники — разработчик стандартов по радиоэлектронике, электротехнике и аппаратному обеспечению вычислительных систем и сетей.

• IFMP — Ipsilon Flow Management Protocol. Протокол на основе меток, регулирующий потоки данных в сети. К потокам IP-пакетов прикрепляются метки второго уровня. Они позволяют осуществлять коммутацию потоков вместо обычной маршрутизации, что ускоряет передачу данных. Протокол описан в RFC 1953 “Ipsilon Flow Management Protocol”.

• IGMP — Internet Group Management Protocol, протокол управления группами интернета. Используется для организации групповой передачи данных.

• IGP — Interior Gateway Protocol. Тип для обеспечения обмена данными маршрутизации внутри автономной системы. IGP делятся на две основные категории: дистанционно-векторные протоколы и протоколы, основанные на состоянии каналов. Примером IGP являются протоколы OSPF (Open Shortest Path First) и RIP (Routing Information Protocol).

• IKE — Internet Key Exchange. Компонент безопасной работы с ключами в сетях VPN. Он позволяет установить безопасное соединение, обмениваясь ключами и удостоверениями. Протокол опирается на Oakley и гарантирует конфиденциальность в процессе установления связи между устройствами.

• Infiniband — технология высокоскоростных сетей, применяемая для задач, требующих быстрой передачи данных и минимальных задержек, например, в сфере суперкомпьютеров. Производством ее компонентов, включая адаптеры и коммутаторы, занимаются такие компании, как Mellanox и Intel. Особенностью Infiniband является возможность масштабирования и использование коммутационной ткани для построения сетевой архитектуры.

• IOCP — Input/Output Completion Port, порт завершения ввода-вывода. Примитив, который позволяет связать несколько хэндлов в ОС Windows. При завершении ввода-вывода на любом из них в очередь, которая связана с портом, добавляется I/O Completion Packet — пакет завершения ввода-вывода.

• IoT — Internet of Things, интернет вещей. Концепция сети передачи данных между устройствами, которые не являются компьютерами общего назначения, оснащенными встроенными средствами и технологиями для взаимодействия друг с другом или с внешней средой. Например робот-пылесос и холодильник.

• IPX/SPX — протоколы связи ОС Novell, популярной в 1990-е годы.

• ISDN — Integrated Services Digital Network, цифровая сеть с интегрированными услугами. Набор стандартов для одновременной передачи голоса, видео, цифровых данных и предоставления других услуг, например факса, по каналам коммутируемой телефонной сети.

• IS-IS — IGP, разработанный ISO для масштабных сетевых структур. Чаще встречается у провайдеров сетевых услуг, также подходит для больших корпоративных систем. Работает на принципах link-state и обеспечивает надежность и масштабирование. Его преимущество — в эффективности использования сетевых ресурсов.

• JMS — Java Message Service. Компонент для обмена сообщениями между приложениями на Java. Этот протокол позволяет выполнять отправку, получение и обработку сообщений в рамках корпоративных систем. Входит в состав Jakarta EE.

• L2F — Layer 2 Forwarding Protocol. Протокол, обеспечивающий VPN через интернет. Он не шифрует данные самостоятельно, а использует другие протоколы для защиты информации. Его цель — передача трафика PPP в туннелях. Разработан Cisco Systems. Описан в RFC 2341 «Cisco Layer Two Forwarding».

• L2TP — Layer 2 Tunnelling Protocol. Протокол, предназначенный для создания VPN. Особенностью L2TP является его способность функционировать в различных сетевых инфраструктурах, включая IP, а также ATM, X.25 и Frame Relay. Работает на уровне сессий и использует для своей деятельности UDP-порт с номером 1701.

• Loopback-интерфейс — то же самое, что и интерфейс локальной петли.

• LUID — Locally Unique IDentifier. Локальный уникальный идентификатор. Обычно в ОС Windows. Непрозрачный идентификатор, уникальный в пределах одного компьютера.

• MAC — Media Access Control, или адрес доступа к среде.  Уникальный адрес, присваиваемый каждому интерфейсу сетевого оборудования, соответствующего Ethernet стандарту.

• MES — Manufacturing Execution Systems. Система, контролирующая работу производства. Отвечает за сбор, хранение и анализ данных из различных источников, таких как машины, датчики, операторы.

• MIB — Management Information Base в SNMP. База управляющей информации. Используется в инфраструктуре протокола SNMP для описания свойств управляемых объектов.

• MII — Media Independent Interface. Интерфейс, разработанный для обеспечения универсального соединения между контроллером MAC и физическим чипом PHY в сетях Fast Ethernet. Позволяет MAC общаться с разными физическими средами, такими как витая пара или оптоволокно, без необходимости изменения самого контроллера. Передача данных в MII осуществляется по 4 бита в каждом направлении с тактовой частотой 25 МГц, что позволяет достигать скорости передачи данных в 100 Мбит/с. Стандартизирован IEEE 802.3u. На данный момент существуют похожие стандарты для более высокоскоростных соединений.

• MIME — Multipurpose Internet Mail Extensions. Стандарт, который позволяет отправлять по электронной почте данные разнообразных форматов. Он также представляет собой набор правил для кодировки контента и структурирования электронных сообщений с целью их корректной передачи через сеть интернет.

• MOM — Message Oriented Middleware, промежуточное программное обеспечение, ориентированное на сообщения. Компонент для связи между системами, работающими на различных платформах. Упрощает процесс разработки за счет обеспечения единой коммуникационной среды, позволяющей приложениям, расположенным на разных устройствах и ОС, взаимодействовать без необходимости заботиться о специфике сетевых протоколов. MOM предоставляет универсальный API для удобства разработчиков, скрывая сложности сетевого общения.

• MPLS — Multiprotocol Label Switching, многопротокольная коммутация по меткам.

• MVC — Model, View, Controller. Архитектурный паттерн, разделяющий приложение на Модель, хранящую данные, Представление, отображаемое пользователю, и Контроллер, выполняющий обработку ввода.

• NAPI — New API. Интерфейс, который позволяет использовать методы объединения входящих сообщений, пока не наберется достаточное количество сообщений и, соответственно, объединение прерываний для сетевых устройств в ядре Linux.

• NAT — Network Address Translation. Механизм в сетях TCP/IP, позволяющий преобразовывать IP-адреса транзитных пакетов. Осуществляется, например, маршрутизатором, который является точкой выхода локальной сети в интернет: адрес отправившего запрос клиента преобразуется в интернет-адрес маршрутизатора и обратно.

• NAT-PMP — NAT Port Mapping Protocol. Сетевой протокол для автоматической настройки параметров NAT и перенаправления портов без участия пользователя.

• NDIS — Network Driver Interface Specification. Стандарт, разработанный Microsoft и 3Com, для интеграции драйверов сетевых карт с операционной системой.

• Netcat — Unix-утилита, которая позволяет устанавливать соединения TCP или UDP для приема и передачи данных.

• Node.js — платформа, включающая интерпретатор JavaScript, набор библиотек и средств ввода-вывода. Позволяет использовать JavaScript как язык общего назначения. В основе асинхронного ввода-вывода Node.js лежит библиотека Libuv.

• OCSP — Online Certificate Status Protocol. Протокол, который центры сертификации используют для определения статуса сертификатов X.509. Позволяет узнать, отозван ли сертификат без использования списка отзывов. Описан в RFC 6960 “X.509 Internet Public Key Infrastructure Online Certificate Status Protocol — OCSP”.

• OOB, или Out-Of-Band, данные — внеполосные данные. Способ обмена данными, при котором они пересылаются по каналу, отдельному от главного потока.

• OSI — Open Systems Interconnection Model, модель взаимодействующих открытых систем.

• OSPF — Open Shortest Path First, протокол поиска кратчайшего пути. Используется для маршрутизации как альтернатива BGP.

• OWASP — The Open Web Application Security Project. Некоммерческая организация, в чьи задачи входит улучшение безопасности программного обеспечения. Она предлагает инициативы, а также реализует проекты для повышения безопасности.

• PAC — программируемые контроллеры автоматизации.

• PDU — Protocol Data Unit. При взаимодействии устройств в компьютерной сети информация передается в специализированных блоках. Каждый такой блок содержит данные пользователя и служебную информацию, необходимую для их обработки в соответствии с правилами выбранного протокола. Эти блоки создаются на различных уровнях сетевой модели, где каждый уровень отвечает за свои функции и имеет собственный формат блока.

• PEP — Python Enhancement Proposal. Документы, посредством которых расширяется язык Python.

• PIM — Protocol Independent Multicast. Семейство протоколов, используемых для распространения мультимедийного контента группам адресатов в сетях на базе IP. Они не зависят от конкретного протокола маршрутизации и работают поверх уже существующих, таких как BGP.

• PKCS — Public Key Cryptography Standards. Спецификации, описывающие стандарты на алгоритмы криптографии с открытым ключом.

• PKI — Public Key Infrastructure. Роли, политики, процедуры, программное обеспечение, инфраструктура для создания, распространения, отзыва и управления цифровыми сертификатами.

• PLC — Programmable Logic Controller, программируемый логический контроллер. Представляет собой программируемую цифровую систему, которая предназначена для управления производственными машинами или процессами через цифровой или аналоговый интерфейс, а также взаимодействия с операторским интерфейсом. Обычно используется для автоматизации технологических процессов и работает без вмешательства человека, иногда в неблагоприятных условиях окружающей среды.

• PNRP — Peer Name Resolution Protocol. Протокол Microsoft для публикации имен узлов в одноранговых сетях и разрешения имен узлов. Не требует сервера, но использует IPv6.

• POSIX — Portable Operating System Interface. Набор стандартов на интерфейсы между операционными системами и приложениями, а также на библиотеки языка C. Его основная цель — обеспечить совместимость между различными операционными системами и сделать прикладные программы переносимыми на уровне исходного кода.

• PPP — Point-to-Point Protocol. Двухточечный протокол канального уровня, используемый для установления прямой связи между двумя узлами сети. Может обеспечить аутентификацию, часто используется для подключения к оборудованию интернет-провайдера.

• PSTN — Plain Simple Telephone Network. Обычная телефонная сеть.

• PPTP — Point-to-Point Tunneling Protocol. Протокол для создания безопасного туннеля между компьютером и сервером или между двумя сетями. Встраивает данные PPP в IP-пакеты, что позволяет передавать их через интернет. Для поддержания туннеля протокол использует отдельное TCP-соединение.

• PVM — Parallel Virtual Machine. Общедоступный программный пакет, позволяющий объединять разнородный набор компьютеров в общий вычислительный ресурс. Предоставляет возможности по управлению процессами посредством механизма передачи сообщений.

• Python — это интерпретируемый язык программирования высокого уровня с динамической строгой типизацией. Python упрощает процесс разработки за счет автоматизированного контроля за памятью, что облегчает поддержку кода. Кроме того, благодаря универсальности Python программы, написанные на этом языке, легко адаптируются к различным операционным системам.

• QEMU/KVM — бесплатный и открытый эмулятор процессора посредством динамической двоичной трансляции. Предоставляет набор различных моделей оборудования и устройств и может запускать разные гостевые операционные системы. Обычно взаимодействует с KVM — виртуальной машиной на основе ядра, чтобы запускать машину с высокой скоростью.

• Qnet — реализация сети в QNX, позволяющая организовать прозрачный обмен данными между машинами, например общую файловую систему. Работает без использования стека TCP/IP, но может быть инкапсулирована в IP-пакеты.

• QNX — POSIX-совместимая микроядерная операционная система реального времени, предназначенная в основном для встраиваемых систем.

• RARP — Reverse Address Resolution Protocol. Протокол обратного разрешения адреса. Используется для получения IP-адреса по MAC.

• RDF — Resource Description Framework. Абстрактная модель представления данных и метаданных, разработанная консорциумом Всемирной паутины. Является набором ресурсов и утверждений о ресурсах в виде, пригодном для машинной обработки. На основе RDF реализуется концепция семантической паутины.

• RDMA — Remote Direct Memory Access. Технология, позволяющая осуществлять непосредственный доступ к ОЗУ одного компьютера через скоростную сеть без задействования операционных систем участвующих машин. Данный метод передачи данных обеспечивает высокий уровень пропускной способности и минимальные задержки времени.

• RIP — Routing Information Protocol. Базовый протокол для установления маршрутов, который обычно используют в масштабах малых сетевых структур. Его задача — обеспечить автоматическое обновление данных о путях передачи данных между устройствами, распространяя информацию о количестве промежуточных точек (хопов) и направлениях от ближайших узлов.

• RS-232 — Recommended Standard 232. Стандарт физического уровня на интерфейс UART. Иными словами, простой последовательный порт.

• RS-485 — Recommended Standard 48. Стандарт на интерфейс асинхронного приемопередатчика с общей шиной. Токовая петля.

• RTT — Round Trip Time («Время приема-передачи»). Время, затраченное на отправку сигнала, плюс время, которое требуется для подтверждения того, что сигнал получен.

• SAN — в ОС Windows обычно System Area Network. Сети, используемые для связи в кластерах через виртуальные адаптеры. В других контекстах, скорее всего, Storage Area Network, или сеть хранения данных.

• SCADA, или Supervisory Control And Data Acquisition, — системы для управления различными производственными и техническими процессами.

• Scala — Java-подобный язык программирования с возможностями функционального программирования.

• SCTP — Stream Control Transmission Protocol, протокол передачи с управлением потоком. Многопоточная альтернатива TCP.

• SDR — Software Defined Radio. Радиоаппаратура, большинство частотных и мощностных параметров которой задается программно, что позволяет пользователю гибко менять их, в том числе динамически, следуя определенному алгоритму.

• SHOUTcast — протокол трансляции потоковых медиаданных, таких как аудио и видео. Позволяет передавать содержимое в MP3 и других форматах через интернет и локальные сети.

• SIGTRAN — Signaling Transport. Комплекс протоколов для связи между классической телефонией и VoIP. Термин введен группой IETF при разработке стандартов, которые позволяют доставлять дейтаграммы с гарантией и адаптировать уровень пользователей под системы SS7 и ISDN. Протоколы SIGTRAN расширяют функционал SS7, сохраняя при этом их основные возможности и методы управления звонками. Они работают через IP и передают данные по протоколу SCTP.

• SIMD — Single Instructions Multiple Data. Архитектурная концепция, которая предполагает одновременную обработку нескольких потоков данных одним потоком команд. Эта модель входит в классификацию систем по Флинну и способствует повышению производительности за счет параллелизации вычислений.

• SmartNIC — Smart Network Interface Card, интеллектуальный сетевой адаптер. Программируемое устройство, которое помимо обеспечения доступа к сети может выполнять операции над сетевыми протоколами более высокого уровня, чем канальный. Это позволяет снизить нагрузку на центральные процессоры серверов и ускорить обработку данных.

• SMT-решатель — решатель Satisfiability Modulo Theories. Система, предназначенная для анализа и доказательства логических выражений. Решатель проверяет истинность или ложность параметризованных высказываний. Решатели применяют в множестве сфер, где требуется формальный подход и формальное доказательство корректности. SMT-решатели расширяют задачу SAT — проблему выполнимости булевых формул, позволяя работать с более сложными выражениями, которые могут включать числа, структуры данных, массивы и строки.

• SNMP — Simple Network Management Protocol, протокол для управления сетевыми устройствами.

• SPI — Service Provider Interface, интерфейс провайдера сервисов. Используется в Windows для написания дополнений к Winsock.

• SQL — Structured Query Language, структурированный язык запросов. Декларативный язык для получения данных из реляционных СУБД, а также управления базой данных и данными в ней.

• SQL-инъекция — внедрение произвольного SQL-кода в запрос. Появляется в результате ошибок при обработке данных, поступающих в систему, — пользовательского ввода, заголовков протоколов, cookies и т.д.

• SRV-запись DNS — записи типа SRV используются некоторыми сетевыми протоколами для создания псевдонимов и балансировки нагрузки. Например, XMPP использует записи SRV для определения местоположения сервера в домене. Вместо прямого подключения к серверу или использования определенного имени хоста сервера, такого как xmpp.example.com, XMPP-клиент будет искать SRV-запись xmpp-client для example.com, а затем подключаться к хосту, указанному в этой записи.

• SS7 — Signalling System № 7. Система протоколов телефонной сигнализации, разработанная для установления и завершения телефонных вызовов телефонных сетей. Помимо этого, выполняет преобразование и трансляцию номеров, биллинг, работу с SMS и прочие услуги.

• SSH — Secure Shell. Протокол для удаленного управления ОС и туннелирования TCP с использованием модели «клиент-сервер». Соединяет клиент, на котором отображается сеанс, с сервером SSH, на котором выполняется сеанс. Обеспечивает надежную аутентификацию, а также зашифрованную передачу данных между двумя компьютерами, подключенными через открытую сеть.

• SSL — Secure Sockets Layer. Криптографический протокол для безопасной связи поверх сокетов. К настоящему времени устарел и заменяется TLS.

• STDCALL — соглашение вызова функций, при котором аргументы функций передаются через стек от последнего к первому. Очистку стека выполняет вызывающая функция. Используется в WinAPI.

• SYN Cookies — техника защиты от SYN-атак, например, с перегрузкой сервера полуоткрытыми соединениями, с использованием SYN-флуда. Основана на специальном алгоритме выбора начального номера последовательности TCP-соединения. При этом сервер, несмотря на заполненную очередь, не отвергает новые запросы, а отвечает клиенту с подтверждением SYN+ACK, избегая хранения данных о соединении. При получении подтверждающего ACK сервер способен реконструировать необходимую информацию для возобновления связи.

• TCP — Transmission Control Protocol, протокол управления передачей.

• TCP/IP — стек протоколов для передачи цифровых данных. Описывает способ передачи данных от информации к получателю. Предполагается прохождение информации через четыре уровня, каждый из которых описывается правилом или протоколом передачи. Наборы правил, решающих задачу по передаче данных, составляют стек протоколов, на которых базируется интернет. Название TCP/IP включает названия двух важнейших протоколов семейства: Transmission Control Protocol — TCP и Internet Protocol — IP, которые были первыми разработаны и описаны в данном стандарте.

• Teredo — протокол, предназначенный для обеспечения совместимости сетей IPv6 и IPv4. Он позволяет IPv6-трафику проходить сквозь устройства с NAT, инкапсулируя данные в UDP.

• TLS — Transport Layer Security. Протокол защиты транспортного уровня, обеспечивающий безопасную передачу данных между узлами в интернете.

• UART — Universal Asynchronous Receiver-Transmitter, универсальный асинхронный приемопередатчик. Блок, позволяющий отправлять данные последовательно, без отдельной линии синхронизации. Основной компонент реализации интерфейса RS-232.

• UDP — User Datagram Protocol, протокол дейтаграмм пользователя.

• Unicode — стандарт кодирования символов. Включает знаки почти всех языковых алфавитов, а также символы, цифры, эмодзи и т.п. Позволяет отказаться от использования кодовых страниц. Имеет несколько способов представления, которые на жаргоне называются кодировками, например известная кодировка UTF-8.

• URAD — Unicast Routing with Area Delivery. Протокол географической маршрутизации, использующий одноадресную рассылку, для соединения отправителя и целевого региона. В пределах целевого региона может использоваться любой протокол управления, независимый от протокола, используемого за его пределами. Например, широковещательная рассылка.

• USB — Universal Serial Bus. Последовательный интерфейс для подключения различных периферийных устройств с функцией Plug'n'Play. Стандарт описывает несколько уровней, начиная с физического, а также конструктив разъемов.

• VLAN — Virtual Local Area Network. Целью VLAN является разделение большой сети на более мелкие изолированные сети, то есть на сегменты.

• VPN — Virtual Private Network. Обобщенное название технологий, позволяющих обеспечить одно или несколько сетевых соединений поверх другой сети. VPN обеспечивают безопасный удаленный доступ. Применяются по-разному, но могут работать вместе для создания более безопасной рабочей среды. Например, в офисах.

• Windows — семейство коммерческих операционных систем корпорации Microsoft, ориентированных на управление с помощью графического интерфейса.

• Windows CE — ОС корпорации Microsoft, предназначенная для мобильных устройств, таких как карманные ПК, сложные калькуляторы, телефоны и системы на встроенных платформах. Отличается от классических ОС Windows, поскольку построена на микроядре, работающем в режиме реального времени.

• WinSock — Windows Sockets API. API сокетов Windows. Техническая спецификация, которая определяет, как сетевое программное обеспечение Windows будет получать доступ к сетевым сервисам.

• WSA — WinSock API. Программный интерфейс сокетов в ОС Windows.

• WWW — World Wide Web, Всемирная паутина. Гипертекстовый сервис интернета.

• z/Architecture — 64-разрядная архитектура IBM для мейнфреймов.

• z/OS — серверная операционная система компании IBM, разработанная для собственных мейнфреймов, наследник OS/390.

• Автономная система — это набор сетей и сетевого оборудования, в том числе маршрутизаторов, которые находятся под единым административным управлением. Для использования в BGP каждой автономной системе присваивается номер. Кроме того, автономным системам выделяется набор IP-адресов.

• Авторизация — предоставление субъекту прав на выполнение определенных действий и процедура проверки прав на действия.

• Алгоритм Нейгла — алгоритм для увеличения производительности сетей TCP/IP за счет сокращения числа необходимых к передаче пакетов. Объединяет небольшие исходящие сообщения в одно, чтобы сократить накладные расходы при отправке. Пока существует отправленный пакет, не подтвержденный получателем, отправитель буферизует данные и отправляет их лишь после получения подтверждения.

• Аутентификация — процедура проверки подлинности. Одна сторона, например пользователь или клиент, проходит аутентификацию, чтобы другая сторона могла установить ее подлинность.

• Бесклассовая адресация — то же, что CIDR.

• Виртуальная частная сеть — то же, что VPN.

• Внеполосные данные — то же, что OOB-данные.

• Горячий резерв — параллельно работающие сервисы или узлы, которые незамедлительно могут перехватить инициативу в случае падения основного ресурса. В холодном резерве, наоборот, требуется предварительный запуск либо инициализация дублирующего ресурса.

• Дескриптор — идентификатор чего-либо. Например, дескриптор открытого файла представляет собой идентификатор потока ввода-вывода, используя который возможно обмениваться с файлом данными. Бывают дескрипторы устройств, сокетов и т.п.

• Домен маршрутизации — группа сетей и оборудования, внутри которой используются протоколы внутридоменной маршрутизации, например IS-IS. Маршрутизация между автономными системами использует протоколы междоменной маршрутизации, такие как EGP и BGP. По сути, то же, что и автономная система.

• Журналирование — ведение журнала некоторых событий, информация о которых записывается в журнал обычно с целью последующей диагностики системы или расследования инцидентов.

• Инкапсуляция — сокрытие данных. В ООП позволяет ограничить доступ пользователя объекта к его атрибутам и не допустить разрушения целостности данных объекта. В сетях обозначает туннелирование одного протокола с использованием другого.

• Интерфейс локальной петли — в контексте сетей это интерфейс, отправка данных на который приводит к их помещению в стек отправителя, то еть это «локальный интерфейс локального компьютера».

• Имитовставка —  уникальный элемент, применяемый в системах проверки подлинности для подтверждения неизменности данных. Признак доверия между сторонами, обменивающимися данными.

• Квитирование — подтверждение приема данных, преимущественно в TCP, а также в других протоколах, обеспечивающих надежную передачу, которые поддерживают данную возможность.

• Классификация Флинна — систематизация компьютерных архитектур, основанная на использовании параллелизма в уровне инструкций и данных.

• Коллизионный домен — сегмент сети, в котором несколько устройств соревнуются за доступ к каналу на передачу. Характерен для беспроводных сетей и шинных топологий, часто используемых в ранних версиях Ethernet.

• Логирование — то же, что и журналирование, английская калька слова logging.

• Манглинг — искажение названий функций. Компиляторы C++ заменяют названия экспортируемых бинарных файлов методов и функций, подставляют к имени метода название класса, количество параметров и некоторые дополнительные символы.

• Мейнфрейм — большой универсальный высокопроизводительный отказоустойчивый сервер, предназначенный для использования в критически важных системах.

• Нода — узел. Компьютер или кластер, представляемый отдельным компьютером.

• ОКС-7 — то же, что SS-7.

• Отказ в обслуживании, или DoS, — поведение системы, не удовлетворяющее спецификации. Некорректная работа либо полная неработоспособность.

• Плоскость данных — часть сети или сетевого устройства, отвечающая за передачу пользовательских данных. В устройстве может заниматься пересылкой данных пользователя с одного интерфейса на другой.

• Плоскость управления — часть сети или устройства для передачи сигнального трафика и маршрутизации. Отвечает за настройку и управление.

• Программируемый логический контроллер — то же, что PLC.

• Протокол состояния сетевого сертификата — то же, что OCSP.

• Распределенное приложение, или распределенная система, — программно-аппаратное решение, состоящее из компонентов, функционирующих на физически удаленных и независимых друг от друга гетерогенных узлах, представляемое для пользователей в виде единой системы. Иными словами, приложение, способное работать одновременно на нескольких независимых машинах. Распределенная система — более общий термин.

• Реентерабельность — свойство кода (функции, метода и т.п.) быть вызванным из нескольких потоков или процессов и при этом отработать корректно.

• Резервирование — дублирование ресурсов. Резерв бывает горячим, когда ресурсы работают параллельно, и если один выйдет из строя, другой возьмет на себя его работу; и холодным, когда один ресурс простаивает, а другой работает и включается, когда выходит из строя первый. Бывают и различные комбинации этих вариантов.

• Резолвер — программа либо машина, используемая для преобразования имени хоста в адрес.

• Сетевой стек ОС — совокупность уровней, обрабатывающих что-либо. Например, сетевой стек включает совокупность уровней от API до сетевых драйверов.

• Сеть «точка-точка» — соединение двух узлов «один к одному» прямым каналом. Сети Wi-Fi, например, работают по другому принципу и представляют тип «точка-многоточка».

• СХД — система или сеть хранения данных. Решение для подключения устройств хранения данных, таких как дисковые массивы, ленточные библиотеки, оптические приводы, к серверам таким образом, чтобы использующие их узлы распознавали подключенные ресурсы как локальные.

• Хэндл — то же, что и дескриптор, но чаще используется в ОС Windows.

• Широковещательный домен — группа коллизионных доменов, соединенных на канальном уровне. В нем все узлы могут передавать данные друг другу с помощью широковещательной рассылки.

Список использованных источников

В процессе работы над книгой мы пользовались как печатными изданиями (книгами и статьями), так и проверенными онлайн-ресурсами.

Список этой литературы мы приводим ниже.

Кроме того, материал книги снабжен большим количеством ссылок на интернет-ресурсы, содержащие дополнительную информацию, полезные сведения, официальную документацию и т.п. Они обозначены нумерованными сносками. Полный список интернет-ресурсов, упоминаемых в книге, вы можете найти в разделе «Список интернет-ресурсов» репозитория книги на GitHub. В электронной версии книги все гиперссылки активны,  и вы можете перейти по ним непосредственно из текста.

Все ссылки — рабочие на момент сдачи книги в печать (апрель 2025 г.).

Для перехода к репозиторию посетите https://github.com/artiomn/network-programming-book-code/.

• Анатомия сетевого стека в Linux от IBM317.

• Описание режимов SEQPACKET и RDM318.

• Подробные различия между Winsock и POSIX Sockets на StackOverflow319.

• Winsock FAQ320.

• QNX TCP/IP Networking321.

• Leslie Lamport — «The Part-Time Parliament»322, 2000

• Калина Алексей — «Консенсус в распределенных системах», 2020323.

• The Ephemeral Port Range324.

• Профессиональный подход к ведению логов, 2020325.

• Linux Networking Documentation — The kernel development community 2020326.

• Habr — Программирование с использованием PCAP, 2017327.

• Habr — «Прозрачный» Squid с разграничением доступа, 2016328.

• Habr — Введение в DPDK: архитектура и принцип работы, 2016329.

• Marek Majkowski — Kernel bypass, 2015330.

• Silver Moon — How to code a Packet Sniffer in C with Linux Sockets, 2020331.

• Wireshark — Ethernet capture setup332.

• Wireshark — WLAN (IEEE 802.11) capture setup333.

• Microsoft Application Architecture Guide, 2nd Edition, 2010334.

• Бунин О. TCP против UDP, или Будущее сетевых протоколов, 2019335.

• Packet Loss336.

• Jorgensen B. Beej's Guide to Network Programming, 2019337.

• Стивенс У. UNIX: разработка сетевых приложений — изд-во «Питер», 2007.

• Kerrisk M. The SO_REUSEPORT socket option, 2013338.

• Vbart. Увеличиваем производительность с помощью SO_REUSEPORT в NGINX 1.9.1339.

• Ларин Д.В., Гетьман А.И. Средства захвата и обработки высокоскоростного сетевого трафика — Труды Института системного программирования РАН — 2021;33(4):49-68.

• anikavoi. Определение blocking-режима TCP-сокета под Windows, 2017340.

• Hidenari Sawashima. Characteristics of UDP Packet Loss: Effect of TCP Traffic, 1997.

• Young W. Winsock Programmer’s FAQ. Articles: Which I/O Strategy Should I Use? — 2015341.

• Алексеев А. Типичные ошибки в сетевых приложениях на C/C++, 2017342.

• Villamil F. Nginx Optimization: understanding sendfile, tcp_nodelay and tcp_nopush, 2014 343.

• IBM — Examples: Socket application designs, 2015344.

• Prateek Singh. Learn Windows Subsystem for Linux. A Practical Guide for Developers and IT Professionals, 2020.

• Network Programming with Qt345.

• POCO Network Programming346.

• Grigorik I. High Performance Browser Networking 347, 2013.

• Протокол QUIC, 2016348.

• ASN.1 Простыми словами, 2012349.

• Mozilla — Writing WebSocket servers, 2022350.

• Э. С. Таненбаум и др. Компьютерные сети, 6-е изд. — Изд-во «Питер», 2024.

• Федотов Н.Н. Форензика: компьютерная криминалистика, 2007.

• MSDN — STRIDE model, 2022351.

• Йосифович П. Работа с ядром Windows — изд-во «Питер», 2021.

• TUN/TAP Notes352.

• Э. Джонс, Д. Оланд. Программирование в сетях Microsoft Windows — изд-во «Питер», 2002.

• McMillan G. Socket Programming HOWTO353.

• Jennings N. — Socket Programming in Python (Guide), 2022354.

• Using a specific network interface for a socket in windows на StackOverflow355.

• П. Сингх. Изучаем подсистемы Windows для Linux356.

• Windows Sockets 2 Application Programming Interface. An Interface for Transparent Network Programming. Revision 2.2.2 — August 7, 1997.

• Д. Рихтер. Windows для профессионалов», 4-е изд. — Изд-во «Питер», 2001.

• Д. М. Харт. Системное программирование в среде Windows, 3-е изд. — Изд-во «Вильямс», 2005.

• Windows Filtering Platform в защите и нападении357 // «Хакер», 2010.

317 https://developer.ibm.com/technologies/linux/

318 https://www.gamedev.net/forums/topic/214842-sock_seqpacket-and-sock_rdm/

319 https://stackoverflow.com/questions/28027937/cross-platform-sockets

320 https://tangentsoft.com/wskfaq/articles/bsd-compatibility.html

321 https://www.mikecramer.com/qnx/momentics_nc_docs/neutrino/sys_arch/tcpip.html

322 https://lamport.azurewebsites.net/pubs/lamport-paxos.pdf

323 https://alexeykalina.github.io/technologies/consensus.html

324 https://www.ncftp.com/ncftpd/doc/misc/ephemeral_ports.html

325 https://nuancesprog.ru/p/8320/

326 https://www.kernel.org/doc/html/v6.9/networking/index.html

327 https://habr.com/ru/articles/337840/

328 https://habr.com/ru/articles/314718/

329 https://habr.com/ru/companies/selectel/articles/313150/

330 https://blog.cloudflare.com/kernel-bypass/

331 https://www.binarytides.com/packet-sniffer-code-in-c-using-linux-sockets-bsd-part-2/

332 https://wiki.wireshark.org/CaptureSetup/Ethernet

333 https://wiki.wireshark.org/CaptureSetup/WLAN

334 https://learn.microsoft.com/en-us/previous-versions/msp-n-p/ee658117(v=pandp.10)

335 https://habr.com/ru/companies/oleg-bunin/articles/461829/

336 https://en.wikipedia.org/wiki/Packet_loss

337 https://beej.us/guide/bgnet/

338 https://lwn.net/Articles/542629/

339 https://habr.com/ru/articles/259403/

340 https://habr.com/ru/articles/335762/

341 https://tangentsoft.com/wskfaq/articles/io-strategies.html

342 https://eax.me/network-application-mistakes/

343 https://www.netguru.com/blog/nginx-tutorial-performance

344 https://www.ibm.com/docs/en/i/7.3?topic=programming-examples-socket-application-designs

345 https://doc.qt.io/qt-5/qtnetwork-programming.html

346 https://pocoproject.org/slides/200-Network.pdf

347 https://www.amazon.com/High-Performance-Browser-Networking-performance/dp/1449344763

348 https://habr.com/ru/companies/infopulse/articles/315172/

349 https://habr.com/ru/articles/150757/

350 https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers

351 https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats

352 https://ldpreload.com/p/tuntap-notes.txt

353 https://docs.python.org/3/howto/sockets.html

354 https://realpython.com/python-sockets/

355 https://stackoverflow.com/questions/2065495/using-a-specific-network-interface-for-a-socket-in-windows/2080495#2080495

356 http://onreader.mdl.ru/LearnWindowsSubsystemLinuxProfessionals/content/index.html

357 https://xakep.ru/2010/12/23/54421/

UNIX. Профессиональное программирование. 3-е изд.

Уильям Стивенс, Стивен Раго

Эта книга заслуженно пользуется популярностью у серьезных программистов во всем мире, поскольку содержит самую важную и практическую информацию об управлении ядрами UNIX и Linux. Без этих знаний невозможно написать эффективный и надежный код. От основ — файлы, каталоги и процессы — вы постепенно перейдете к более сложным вопросам, таким как обработка сигналов и терминальный ввод/вывод, многопоточная модель выполнения и межпроцессное взаимодействие с применением сокетов. В общей сложности в этой книге охвачены более 70 интерфейсов, включая функции POSIX асинхронного ввода/вывода, циклические блокировки, барьеры и семафоры POSIX.

BPF: профессиональная оценка производительности

Брендан Грегг

Инструменты оценки производительности на основе BPF дают беспрецедентную возможность анализа систем и приложений. Вы сможете улучшить производительность, устранить проблемы в коде, повысить безопасность и сократить расходы. Книга «BPF: профессиональная оценка производительности» — ваш незаменимый гайд по применению этих инструментов. Брендан Грегг — эксперт и пионер проекта BPF — представляет более 150 готовых инструментов анализа и отладки, рекомендации по их применению, а также пошаговые инструкции по разработке ваших собственных инструментов. Вы узнаете, как анализировать процессоры, память, дисковый ввод/вывод, файловую систему, сети, языки программирования, приложения, контейнеры, гипервизоры, безопасность и ядро. Вы сможете выработать глубокое понимание того, как улучшить буквально любую Linux-систему или приложение.

Software Dynamics: оптимизация производительности программного обеспечения

Ричард Л. Сайтс

Программное обеспечение — начиная с мобильных и облачных приложений, заканчивая видеоиграми и системами управления автономным транспортом — становится все более и более ограниченным по времени. Оно должно обеспечивать надежные результаты плавно, последовательно и практически мгновенно. Неспособность гарантировать это приведет к недовольству потребителей, а в некоторых случаях даже может создать риск для человеческих жизней. Когда комплексное ПО работает плохо или дает сбой, инженерам необходимо выявить и исправить первопричины. Задача непростая, и для ее решения существовало не так уж много инструментов. Ричард Л. Сайтс, эксперт по анализу производительности, решает задачу напрямую, предлагая действенные способы и профессиональные инструменты для выявления динамики сложных, ограниченных по времени программ, а также для повышения надежности и устранения проблем с производительностью. За плечами автора несколько десятков лет практической работы и обучения профессиональных разработчиков. Он знакомит читателя с принципами и техниками, которые применимы в любой среде, начиная со встраиваемых устройств и заканчивая дата-центрами, и подкрепляет их примерами на основе подключенных по Ethernet систем x86 и ARM под управлением Linux. Опираясь на полученную с помощью KUtrace информацию, читатели смогут использовать конкретные решения, а не просто перебирать техники, такие как отключение кэша или ядер.

Производительность систем

Брендан Грегг

Книга посвящена концепциям, стратегиям, инструментам и настройке операционных систем и приложений на примере систем на базе Linux. Понимание этих инструментов и методов критически важно при разработке современного ПО. Применение стратегий, изложенных в обновленном и переработанном издании, позволит перформанс-инженерам улучшить взаимодействие с конечными пользователями и снизить затраты, особенно для облачных сред. Брендан Грегг – эксперт в области производительности систем и автор нескольких бестселлеров — лаконично, но емко излагает наиболее важные сведения о работе операционных систем, оборудования и приложений, которые позволят специалистам быстро добиться результатов, даже если раньше они никогда не занимались анализом производительности. Далее автор дает детальные объяснения по применению современных инструментов и методов, включая расширенный BPF, и показывает, как добиться максимальной эффективности ваших систем в облачных, веб- и крупных корпоративных средах.

Content

Артем Нечипорук, Алексей Баринов
Сетевое программирование. От основ до приложений

Создатели книги

Авторы

Предисловие

Глава 1. Сетевое взаимодействие, сокеты, порядок байтов

Глава 2. Адресация

Глава 3. Схема работы сети в ОС и в разных языках. UNIX и дейтаграммные сокеты

Глава 4. Простой обмен данными. Raw-сокеты

Глава 5. Соединение. Потоковый обмен данными. Серверный API

Глава 6. Внеполосные данные. Пространства имен

Глава 7. Сокет в ядре Linux

Глава 8. Управление сокетами

Глава 9. Вспомогательные данные

Глава 10. Управление сетевыми интерфейсами

Глава 11. Сокеты Netlink

Глава 12. Библиотеки Netlink

Глава 13. Специальные файловые системы

Глава 14. Введение в сетевое программирование для ОС Windows

Глава 15. Адресация в ОС Windows

Глава 16. Сокетный API в ОС Windows

Глава 17. Альтернативы сокетам в ОС Windows

Глава 18. Управление сетью в ОС Windows

Глава 19. IP Helper API

Глава 20. WinInet, WinHTTP, MFC, WFP, WSL

Глава 21. Проксирование, инкапсуляция

Глава 22. Перехват и захват трафика

Глава 23. TUN/TAP-интерфейсы. Техника Kernel bypass

Глава 24. Проблемы сетевых приложений, диагностика и отладка

Глава 25. Поиск ошибок и обнаружение места сбоя

Глоссарий

Список использованных источников

UNIX. Профессиональное программирование. 3-е изд. Уильям Стивенс, Стивен Раго

BPF: профессиональная оценка производительности Брендан Грегг

Software Dynamics: оптимизация производительности программного обеспечения Ричард Л. Сайтс

Производительность систем Брендан Грегг