В интернете никто не знает, что ты собака.
Питер Штайнер, The New Yorker, 1993
В этой главе мы научимся обмениваться данными через сокеты без соединения на примере UDP. Этот протокол имеет определенные преимущества по скорости передачи данных перед TCP, но у него есть и недостатки, такие как отсутствие гарантий доставки пакетов и сохранения порядка их следования.
Тем не менее он незаменим для таких задач, как трансляция видео или аудио в режиме реального времени или других приложений, требующих высокой скорости передачи за счет возможной потери части данных.
Мы, наконец, рассмотрим API отправки и приема данных. И используя его, реализуем «клиент» и «сервер» для обмена без установки соединения.
Затем мы изучим raw-сокеты, которые позволяют отправлять и получать заголовки протоколов, сформированные на прикладном уровне. Используя их, мы реализуем взаимодействие по протоколу ICMP, который является частью стека TCP/IP и служит для передачи служебных и диагностических сообщений между узлами в сети, что играет критически важную роль в обслуживании интернет-коммуникаций, позволяя отслеживать состояние инфраструктуры.
Мы реализуем наиболее распространенный вариант использования ICMP — утилиту Ping для проверки доступности узлов в сети. И посмотрим, как на примере данной утилиты raw-сокеты используются в разных языках, в частности Python и Go.
Выше мы уже видели пример сокетов, ориентированных на сообщения. Они проще в использовании, чем потоковые, так как не требуют самостоятельной сборки данных из фрагментов. Кроме того, рассмотренные сокеты обеспечивали взаимодействие по UDP, то есть без установки соединения.
Рассмотрим подробнее API для обмена данными, который мы уже использовали в примерах.
Служат для отправки и приема дейтаграмм. Используются при обмене данными без соединения.
Прототипы функций:
#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 очень прост, как и API, работающий с ним. Однако стоит помнить, что сеть — это ненадежная среда. Поэтому в чистом виде UDP обычно не используют, а применяют как транспорт для более надежных протоколов, ведь отсутствие гарантии получения данных удаленным абонентом может приводить к так называемой проблеме коммуникационных блокировок.
Предположим, что приложение отправляет какие-то данные, например команду, а затем сразу читает ответ. Если удаленное приложение не получит данные из-за проблем в сети, оно останется в состоянии ожидания команды, а приложение, которое отправило данные, будет бесконечно ожидать ответа.
Подобная проблема решается относительно просто: установкой таймера. Предполагается, что если ответ не поступил в течение некоторого времени, следует прервать ожидание получения данных и отправить команду повторно.
Но это сразу влечет другие проблемы: например, что, если таймер истек, но первая команда не потерялась, а лишь ушла по другому маршруту и поступит позже, чем вторая? Тогда будут получены две команды и отправлены два ответа.
Решением служат сложные протоколы, такие как TCP. О разработке своих протоколов мы поговорим в книге 2.
Рассмотрим код сервера, пример работы которого показан на рис. 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.
Иногда в доменах 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, или 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 — утилита для проверки целостности и качества соединений в сетях на основе 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: .
Внимание! Вместо пакета эхо-ответа с типом 0 и кодом 0 вы можете получить странный результат с типом 69. Причина в том, что некоторые системы, в частности Linux, не удаляют заголовок IP-пакета:
• 69 — это 0x45;
• 4 — версия IP;
• 5 — минимально допустимое значение его поля IHL, которое означает длину IP-дейтаграммы в 32-битных словах. Если перевести ее в байты, получим: 5 * 4 = 20. Это и есть смещение в байтах для начала ICMP-пакета.
Рис. 4.5. Простой ping
Рассмотрим, как реализован 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.
Пример, рассмотренный выше, является кросс-платформенным. Он будет работать на большинстве ОС, в том числе Windows. Однако для Windows существует специализированный API и библиотеки для работы с ICMP. Как ими пользоваться, будет показано в главе 18, посвященной Windows IP Helper API.
На 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++.
Рассмотрим, как реализован 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 напишите клиент на С++, который отправляет введенную пользователем строку данных.