Skip to content

Latest commit

 

History

History
611 lines (446 loc) · 33.5 KB

lab03-udp.md

File metadata and controls

611 lines (446 loc) · 33.5 KB
title lang
Лабораторная работа № 3. Блокирующие UDP-сокеты
ru

Цель работы

#. Изучить ключевые функции API сокетов. #. Научиться работать с UDP-сокетами в блокирующем режиме.

Задание

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

  1. При запуске программа запрашивает у пользователя IP-адрес и порт, с которых сможет в дальнейшем принимать сообщения.
  2. Пользователь выбирает, что нужно сделать: принять сообщение, отправить сообщение или завершить работу.
  3. Если выбран прием сообщения, программа ожидает, когда его пришлют на заданный ранее адрес и порт, после чего отображает сообщение вместе с адресом и портом отправителя.
  4. Если выбрана отправка сообщения, пользователь вводит адрес получателя, порт получателя и текст сообщения, после чего оно отправляется.
  5. Выполнение возвращается к пункту 2.

Пример работы (ввод показан жирным, пример вывода показан курсивом).

Enter listening address:
host: 0.0.0.0
port: 1234
 
(R)eceive, (s)end, or (q)uit? s
Enter target address:
host: 192.168.1.13
port: 2345
Message: Hello!
 
(R)eceive, (s)end, or (q)uit? r (выполнение приостанавливается)
From host: 192.168.1.14
From port: 4321
Message: test... test... test...

(@) Создайте новый проект lab03-udp и подготовьте программу к работе с API сокетов, как это делалось на предыдущей ЛР.

1. Подключите `<winsock2.h>`.
2. Обрамите код `main()` вызовами `WSAStartup()` и `WSACleanup()`.
3. Добавьте библиотеку `ws2_32` в список компоновки.
4. В параметры компоновки *(Project → Build options… → Linker settings,*
    поле *Additional options*) добавьте `-static`.

Убедитесь, что программа собирается и запускается без ошибок.

Параметр -static указывает собирать программу статически, то есть код встроить полный код функций стандартной библиотеки в исполняемый файл, а не загружать эти функции из внешней библиотеки. Благодаря этому, программу можно будет запускать извне IDE независимо от наличия и местоположения стандартной библиотеки (DLL).

Создание сокета

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

Сокет создается функцией socket(), при этом определяются его основные свойства:

  • C каким семейством адресов (address family) он будет работать: IPv4, IPv6 или другим. Доступные варианты зависят от платформы: например, в Linux есть локальные сокеты (UNIX domain sockets), отсутствующие в Windows, но там, с другой стороны, через сокеты доступна связь по инфракрасному порту или Bluetooth.

  • Какие возможности предоставляет сокет. Основные доступные варианты: SOCK_DGRAM — дейтаграммные сокеты и SOCK_STREAM — потоковые сокеты (см. ниже).

  • Какой протокол транспортного уровня будет использоваться: UDP, TCP или иной. Фактически, доступные протоколы определяются предыдущим пунктом.

Дейтаграммные сокеты (SOCK_DGRAM) обычно используют UDP (user datagram protocol) и предоставляют такие возможности:

  • Сохранение границ между сообщениями (message bounds preservation): если программа отправляет два сообщения (например, 1 КБ и 1 КБ), они будут получены по отдельности, т. е. не могут быть «склеены» в 2 КБ.

  • Ограниченный размер сообщения: зависит от настроек сети, но в типичном случае IPv4 и локальной сети Ehternet — 1472 байта.

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

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

(@) Создайте дейтаграммный сокет, работающий в сетях IPv4 по UDP:

`SOCKET channel = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);`

(@) Проверьте наличие ошибок. Согласно документации, в случае неудачи функция socket() возвращает INVALID_SOCKET.

```
if (channel == INVALID_SOCKET) {
    const int error = WSAGetLastError();
    cerr << "ERROR: socket() = " << error << '\n';
    return 1;
}
```

Задание адресов

Сокет принадлежит процессу, выполняющемуся на машине-узле сети. Узел идентифицируется адресом IP. Сокет идентифицируется портом (port) — двухбайтовым целым числом от 1 до 65535. Порты ниже 1024 — привилегированные: только программы, запущенные пользователем с особыми правами (обычно администратором) имеют право пользоваться ими. Поэтому в лабораторной работе следует выбирать порты с большими номерами, например, 1234.

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

Технически любая программа (протокол) может работать на любом порту. Однако принято по возможности запускать их на известных (well-known) портах, например, web-сервер на порту 80 или DNS-сервер на порту 53. По этой же причине иногда говорят и пишут «порт HTTP» или «порт DNS».

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

sockaddr_in
ask_endpoint() {

Хост и порт можно ввести как обычные переменные, строку и число:

    cout << "   host: ";
    string host;
    cin >> host;

    cout << "   port: ";
    uint16_t port;
    cin >> port;

Объявим переменную-результат. В документации сказано, что все неиспользуемые поля должны быть обнулены. Для этого можно всю память, занятую структурой, сначала заполнить нулями с помощью функции memset(). Помимо заполнителя (0) она принимает адрес и размер заполняемой области памяти.

    sockaddr_in endpoint;
    ::memset(&endpoint, 0, sizeof(endpoint));

Член sin_family структуры sockaddr_in всегда равен AF_INET.

    endpoint.sin_family = AF_INET;

IP-адрес (член sin_addr) представлен структурой in_addr с единственным полем — четырехбайтовым целым s_addr. Это число содержит байты IP-адреса в сетевом порядке байт. Получить такое число из строки (const char*) с IP-адресом можно функцией inet_addr(). Для передачи ей из переменной host извлекается указатель на хранимые символы:

    endpoint.sin_addr.s_addr = inet_addr(host.c_str());

Порт в структуре-адресе хранится в сетевом порядке байт.

Внимание! Если не сделать перевод порядка байт, программа может отработать без ошибок, но не с тем портом, который ввел пользователь. Например, введенный 1088 (0x40, 0x04 в little-endian) будет воспринят как 16388 (0x4004).

    endpoint.sin_port = ::htons(port);
    return endpoint;
}

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

(@) Привяжите сокет к выбранному адресу:

```
cout << "Listening address:\n";
const sockaddr_in address = ask_endpoint();
const int error = ::bind(channel, (const sockaddr*)&address, sizeof(address));
```

(@) Проверьте наличие ошибок, так как привязка может не удаться:

```
if (error < 0) {
    error = WSAGetLastError();
    cerr << "ERROR: bind() = " << error << '\n';
    return 1;
}
```

(@) В бесконечном цикле запрашивайте у пользователя, что нужно сделать:

```
char answer = '?';
while (answer != 'q') {
    cout << "(R)eceive, (s)end, or (q)uit? ";
    cin >> answer;
    cin.ignore();

    switch (answer) {
    case 's':
        do_send(channel);
        break;
    case 'r':
        do_receive(channel);
        break;
    case 'q':
        continue;
    default:
        cerr << "Please enter 'r', 's', or 'q'!\n";
    }
}
```

Здесь используются функции do_send() и do_receive(), в которые будет заключен код для отправки и получения сообщений. Нужно объявить их перед main():

void do_send(SOCKET channel);

и реализовать ниже main(), пока пустыми, подобно ЛР № 2:

void
do_send(SOCKET channel) {
}

(@) Объявление и пустую реализацию для do_receive() напишите самостоятельно.

Прием данных

Для приема данных предназначена функция recvfrom():

int recvfrom(
        SOCKET channel,
        char* buffer, int buffer_length,
        int flags,
        struct sockaddr* from, int* from_length);

При ее вызове выполнение программы приостанавливается до тех пор, пока нельзя будет завершить операцию, то есть пока не прибудут данные. Это и называется блокирующим режимом работы сокетов. То же относится и к остальным операциям, но для recvfrom() это наиболее актуально. Если данные пришли раньше, чем была вызвана recvfrom(), она успешно считает их сразу же из буфера ОС. Но если данные приходят очень быстро, буфер ОС переполнится, и часть дейтаграмм будет сброшена прежде, чем быть считанными через recvfrom().

Ее параметры:

  • привязанный к адресу и порту сокет;
  • адрес буфера, куда будут записаны принятые данные;
  • наибольшее количество байт, которые функция сможет записать в буфер;
  • (опционально, можно передать 0) флаги для особых режимов приема;
  • (опционально, можно передать NULL) указатель на структуру, которая может быть заполнена адресом отправителя, и на переменную, содержащую размер этой структуры.

Наибольший размер дейтаграммы заведомо не может превышать размер кадра Ehternet, равный 1536 (на самом деле, еще меньше):

array<char, 1536> message;

Вызовем функцию приема данных с минимально необходимыми параметрами:

int result = ::recvfrom(channel, &message[0], message.size(), 0, NULL, NULL);

Возвращаемое значение result может быть:

  • положительным — тогда это количество байт, записанных в буфер в результате успешного приема дейтаграммы;
  • нулевым — в случае прихода пустой дейтаграммы (может означать иное в зависимости от дополнительных флагов);
  • отрицательным — если произошла ошибка.

В случае успеха следует распечатать прибывшие данные:

cout.write(&message[0], result);

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

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

Как проверить, что прием данных работает, если отправка еще не написана?

Существует стандартная (в *nix) программа netcat (nc, ncat), позволяющая открыть сокет и отправлять туда данные со стандартного ввода. На Windows netcat поставляется в составе пакета программ Nmap.

(@) Запустите командную строку и добавьте путь к netcat в список путей, по которым ОС ожидает найти запускаемые программы: set PATH=D:\Soft\Nmap;%PATH%

Примечание. Вне лаборатории путь установки Nmap может быть другим.

(@) Запустите свою программу. В качестве адреса привязки укажите 127.0.0.1, а порт привязки — 1234. Выберите режим приема (r).

(@) Отправьте проверочное сообщение в свою программу при помощи ncat:

```
echo текст сообщения | ncat 127.0.0.1 1234 -u
```

Ключ -u означает работу по UDP.

Утилита позволяет не только отправлять, но и принимать данные при запуске с ключом -l (listen):

ncat -l 0.0.0.0 1234 -u

(@) Убедитесь, что сообщение доставлено в программу. Зафиксируйте команду вызова ncat и вывод собственной программы для отчета.

О специальных адресах. Из лекций известно, что и 127.0.0.1, и 0.0.0.0 являются специальными адресами IP. Адрес 127.0.0.1 означает «данный узел» и действителен только в пределах машины. Таким образом, к 127.0.0.1 можно привязать сокет, но он сможет принимать только пакеты, отправленные с той же машины. Адрес 0.0.0.0 означает «все интерфейсы данной машины», то есть сокет, привязанный к 0.0.0.0, может получать пакеты, направленные с других машин на внешний адрес данной (наподобие 192.168.0.2) или с той же машины на 127.0.0.1. Указывать сам 0.0.0.0 в качестве адреса получателя нельзя.

Отправка данных

Отправка данных в функции do_send() требует ввода адреса получателя:

cout << "Enter target address:\n";
const sockaddr_in address = ask_endpoint();

Для отправки дейтаграмм используется функция sendto():

int sendto(
        SOCKET channel,
        const char* buffer, int buffer_length,
        int flags,
        const struct sockaddr* to, int to_length);

Принципиально sendto() может блокироваться, хотя реально такие условия наступают в редких случаях за рамками ЛР.

Ее параметры:

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

Данные для отправки — сообщение, вводимое с клавиатуры:

std::string message;
std::getline(cin, message);

Функция sendto() вызывается так:

int result = ::sendto(channel, &message[0], message.size(), 0,
        (const sockaddr*)&address, sizeof(address));

Функция может вернуть:

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

(@) Напишите код, проверяющий результат отправки данных:

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

(@test) Запустите два экземпляра своей программы. Настройте их слушать разные порты на адресе 0.0.0.0. Проверьте работу программы:

* в первом экземпляре выберите режим приема;
* во втором экземпляре выберите режим отправки и пошлите сообщение
    в первый экземпляр;
* по приходе сообщения в первый экземпляр, переключите второй в режим
    приема и отправьте в него сообщение из первого экземпляра;
* зафиксировав вывод обоих экземпляров в отчете, завершите их работу (`q`).

Получение адреса отправителя после приема

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

Для этого предпоследним параметром необходимо ей передать адрес структуры sockaddr_in (в случае IPv4), поля которой будут заполнены адресом отправителя. Функция позволяет работать не только с IPv4, поэтому указатель принимает не на sockaddr_in, а на структуру sockaddr, поэтому необходимо приведение типов.

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

(@) Измените функцию do_receive(), добавив определение адреса отправителя:

sockaddr_in address;
int length = sizeof(address);
int result = ::recvfrom(..., (struct sockaddr*)&address, &length);

Для пользователя необходимо напечатать поля sin_addr и sin_port.

Для перевода поля sin_addr (4 байта) в текстовый вид IP-адреса можно воспользоваться функцией inet_ntop(), которая в Windows называется InetNtop(). Ее параметры:

  • семейство адресов (AF_INET для IPv4);
  • указатель на байты адреса (адрес поля address.sin_addr);
  • указатель на буфер символов, куда будет записан результат;
  • максимальное количество байт буфера, которые функция может заполнить.

Последняя пара параметров аналогична буферу для данных recvfrom() и его длине. Размер буфера должен быть не меньше INET_ADDRSTRLEN.

В Windows XP доступна только inet_ntoa(). Последняя является более простой и многие примеры в интернете ее используют. Ей не требуется передавать буфер для результата, потому что она использует скрытую глобальную переменную, указатель на которую возвращает. Это делает inet_ntoa() небезопасной для реальных приложений, работающих с несколькими сокетами одновременно (как в ЛР № 6), и в любом случае, чтобы сохранить ее результат, его нужно копировать.

(@) Воспользуйтесь inet_ntop()/InetNtop(), а если они недоступны - inet_ntoa(), чтобы напечатать IP-адрес отправителя.

Поле sin_port заполняется в сетевом порядке байт, поэтому перед выводом порядок байт нужно изменить функцией ntohs().

(@) Добавьте печать порта отправителя в функцию do_receive().

(@) Повторите эксперимент из пункта @test. Убедитесь, что порт отправителя совпадает с выбранным портом привязки в каждом случае.

Широковещательная рассылка (broadcast)

Широковещательная рассылка позволяет отправить дейтаграмму всем узлам локальной сети, ведущим прием на определенном порту. Для этого достаточно указать адресом назначения 255.255.255.255. Однако перед этим необходимо (один раз) настроить сокет на возможность отправки таких дейтаграмм.

Настройка различных опций сокетов выполняется setsockopt():

int setsockopt(
    SOCKET channel,
    int level, int option_name,
    const char* value, int length);

Ее параметры:

  • настраиваемый сокет;
  • уровень (категория) и имя (идентификатор) опции; опции с их уровнями и идентификаторами перечислены и описаны в документации;
  • указатель на новое значение опции и размер значения по этому адресу.

Для включения широковещательной рассылки используется опция SO_BROADCAST уровня SOL_SOCKET. Ее значение — целое число, 1 (вкл) или 0 (выкл). Включение широковещательной рассылки для сокета не мешает вести обычную, поэтому в учебной программе можно включать ее безусловно.

(@) Перед началом основного цикла работы добавьте включение широковещательной рассылки для сокета. int enabled = 1; setsockopt(channel, SOL_SOCKET, SO_BROADCAST, (const char*)&enabled, sizeof(enabled));

(@) Добавьте обработку возможных ошибок вызова setsockopt().

(@) На двух компьютерах (договоритесь с коллегой) запустите по экземпляру программы, привязанных к адресу 0.0.0.0 и одному и тому же порту, на прием. На одном из этих компьютеров запустите еще один экземпляр, из которого отправьте широковещательную дейтаграмму на порт принимающих экземпляров. Зафиксируйте вывод всех экземпляров в отчете.

Общее контрольное задание {.alert}

Не нужно делать, предложенный способ недоступен в Windows, а корректный выходит за рамки ЛР.

Добавьте в программу возможность только проверить, поступала ли дейтаграмма на адрес, к которому привязан сокет, и если нет, не ждать ее прибытия. Для этого добавьте четвертое действие c (check) наряду с r, s и q. Если дейтаграмма прибыла, оно должно делать то же, что и r, иначе печатать сообщение о том, что новых дейтаграмм нет. Это реализуется через специальный флаг recvfrom() и обработку ее результата. Демонстрацию работы нового режима занесите в отчет, снабдив комментариями, в какие моменты выполнялись проверки и отправлялись дейтаграммы.

Контрольные вопросы

#. Что в API сокетов называют семейством адресов? Приведите примеры семейств и адресов, относящихся к ним.

#. Какие сокеты называют дейтаграммными (перечислите их особенности)?

#. Какие сокеты называют блокирующими? Приведите примеры, как это проявляется.

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

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

#. Опишите поля структуры sockaddr_in и способы их заполнения.

#. Укажите назначение специальных адресов IPv4 и диапазонов: 127.0.0.1, 0.0.0.0, 255.255.255.255, 172.16.0.0/12.

#. Какие порты называют привилегированными? Приведите примеры известных привилегированных портов не из описания ЛР.

#. Что произойдет, если попытаться принять дейтаграмму, предоставив recvfrom() буфер недостаточно большого размера? Ответ обоснуйте документацией.

#. Что произойдет, если попытаться отправить sendto() дейтаграмму большего размера, чем допустимо в данной сети? Ответ обоснуйте документацией.

#. Приведите две команды: одна запускает netcat на прием данных по UDP на порту 52341, вторя шлет дейтаграмму на этот порт через netcat же.