Научиться создавать серверы TCP в блокирующем режиме работы сокетов.
Реализовать сервер протокола обмена файлами из ЛР № 4.
После запуска сервер требует ввода адреса и порта для привязки и приема входящих подключений. Затем сервер бесконечно принимает новое подключение и обслуживает запросы клиента до его отсоединения (то есть обслуживается один клиент за раз).
lab05-tcp-server, подключите необходимые библиотеки для работы с API сокетов. Для Windows инициализируйте API.Сервер TCP работает по более сложной схеме, чем клиент (см. ту же презентацию, что в ЛР № 4):
socket(). Изначально он ничем не отличается от сокета-передатчика, как в клиенте.bind().listen().accept(). При этом создается сокет-передатчик для данных принятого соединения.send() и recv().closesocket() или close().closesocket() или close().В смысле ресурсов сокет-слушатель соответствует одному порту, через который клиенты могут подключаться к приложению, а сокет-передатчик — одному соединению. В простейшей реализации этой ЛР используется только один сокет-передатчик за раз, но их может быть много и они могут работать параллельно (ЛР № 6—7).
Создайте сокет-слушатель:
auto listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);При помощи функции ask_endpoint() из предыдущих ЛР запросите адрес и порт для привязки и привяжите к ним сокет функцией bind().
Переведите сокет в режим слушателя:
::listen(listener, 3)Второй параметр listen() — размер очереди входящих соединений. Он важен, если в то время, пока сервер обслуживает одного клиента (то есть пока не вызвана accept()) попытаются присоединиться новые. До трех первых из них станут в очередь на подключение (ОС проведет само подключение, но не позволит обмениваться данными), прочие сразу получат ошибку подключения. Максимально длинная очередь обозначается константой SOMAXCONN.
Вызов accept() для принятия нового подключения — блокирующий, то есть выполнение программы останавливается на нем, пока извне не попытается подключиться клиент. Помимо сокета-слушателя accept() принимает указатель на адрес и на размер адреса подключившегося клиента, полностью аналогично функции recvfrom() с ее адресом отправителя. При ошибке accept() возвращает INVALID_SOCKET (Windows) или -1 (*nix).
В бесконечном цикле ведите прием подключений:
while (true) {
auto channel = ::accept(listener, nullptr, nullptr);
std::clog << "info: client connected\n";
serve_requests(channel);
::closesocket(channel);
std::clog << "info: client disconnected\n";
}Добавьте обработку ошибок accept() — прерывайте цикл при ошибке.
Добавьте получение адреса подключившегося клиента и его печать перед вызовом serve_requests(), как в ЛР № 3 для recvfrom().
Добавьте закрытие сокета после окончания цикла:
::closesocket(listener);Для программы-сервера избежание утечки ресурсов еще более актуально, чем для клиента, поскольку на обслуживание каждого соединения заводится (временно расходуется) новый ресурс-сокет.
Проверьте работу программы — ее способность принимать подключения.
Временно замените вызов serve_requests() на прием единственного байта:
char byte;
recv(channel, &byte, sizeof(byte), 0);При помощи netcat присоединитесь к ней:
nc -nv 127.0.0.1 1234Отправьте единственный байт (нажми Enter в netcat), чтобы завершить соединение (сервер считате один байт, завершит recv() и вызовет closesocket()).
Обслуживание запросов — еще один цикл:
void
serve_requests(SOCKET channel) {
while (serve_request(channel) {}
}
При обслуживании одного запроса нужно считать размер и тип сообщения, затем действовать и формировать ответ в зависимости от типа:
bool send_error(SOCKET channel, const std::string& error);
bool serve_file(SOCKET channel, uint32_t path_length);
bool serve_list(SOCKET channel);
bool process_unexpected_message(SOCKET channel, uint32_t length, Type type);
bool
serve_request(SOCKET channel) {
uint32_t length;
receive_some(client, &length, sizeof(length));
length = ::ntohl(length);
Type type;
receive_some(client, &type, sizeof(type));
switch (type) {
case TYPE_GET:
return serve_file(client, length - 1);
case TYPE_LIST:
return serve_list(client);
default:
return process_unexpected_message(client, length, type);
}
}
Используются receive_some() и send_some() из ЛР № 4.
Функция server_request() должна возвращать true, если запрос успешно обслужен, и false в противном случае — при ошибках или отключении клиента.
Сетевые приложения должны работать корректно при любых прибывающих данных. В данном случае известно, что длина запроса клиента не превышает 300 байтов (самый длинный запрос содержит имя файла, которое протокол же ограничивает 255 байтами). Целесообразно вынести это значение в константу за пределами функций:
const uint32_t MAX_MESSAGE_LENGTH = 300;
После преобразования длины добавьте ее проверку. В случае нарушения вызывайте функцию, отправляющую клиенту сообщение об ошибке:
bool send_error(SOCKET channel, const std::string& message);Реализуйте send_error().
Длина сообщения с ошибкой складывается из длины типа (1 байта) и длины сообщения. Длина передается в сетевом порядке байтов.
bool
send_error(SOCKET channel, const std::string& error) {
const uint32_t length = ::htonl(sizeof(Type) + error.size());
send_some(channel, &length, sizeof(length));
У сообщений об ошибке специальный тип, и клиенты из ЛР № 4 умеют его обрабатывать:
const Type type = TYPE_ERROR;
send_some(channel, &type, sizeof(type));
Содержимое сообщения - собственно текст ошибки:
send_some(channel, &error[0], error.size());
return true;
}
process_unexpected_message() — точно такую же, как process_unexpected_response() из ЛР № 4. В ее реализации потреюуется и hex_dump() из ЛР № 2.Ключевая функция send_file() обслуживает запрос на загрузку файла. Она зеркальна функции download_file() из ЛР № 4.
send_file() с обработкой возможных ошибок (которая не делается в приведенном ниже коде).Имя файла для загрузки не передается, а принимается. Буфер для приема в виде вектора заполняется нулями и на один байт больше, чем нужно. Дополнительный байт не заполняется и остается '\0', таким образом указатель на начало вектора является указателем на завершающуюся нулем строку, т. н. строку C.
bool
serve_file(SOCKET channel, uint32_t path_length) {
std::vector<char> path(path_length + 1, '\0');
receive_some(channel, &path[0], path_length);
Полученная строка C используется для открытия файла. В случае любых ошибок при открытии клиенту сообщается о невозможности доступа к файлу.
std::fstream input(&path[0], std::ios::in | std::ios::binary);
if (!input) {
return send_error(channel, "file is inaccessible");
}
При работе с файлом есть текущая позиция чтения из него: при открытии она 0, если прочитать 10 символов, она станет 10, а если еще 10 — станет 20 и т. д. Можно узнать позицию методом tellg() и изменить ее методом seekg(). Чтобы определить размер файла, можно сместиться к его концу (на нулевое смещение от конца), узнать эту позицию и вернуться в начало:
input.seekg(0, std::ios::end);
const auto size = input.tellg();
input.seekg(0, std::ios::beg);
Размер ответа — сумма размера типа (1 байт) и размера файла (size байтов); передается в сетевом порядке байт:
const uint32_t length = ::htonl(sizeof(Type) + size);
send_some(channel, &length, sizeof(length));
Type type = TYPE_GET;
send_some(channel, &type, sizeof(type));
Чтение файла и отправка его содержимого по сети происходит аналогично приему: из файла читаются блоки фиксированного размера и отправляются по сети, пока не будет достигнут конец файла. Таким образом возможно отправлять даже очень крупные файлы, загружая в память лишь небольшие их фрагменты.
while (true) {
std::array<char, 4096> buffer;
auto bytes_to_send = input.readsome(&buffer[0], buffer.size());
Результат чтения из файла стоит проверять на ошибки:
if (input.bad()) {
std::fprintf(stderr, "error: %s: I/O failure %d\n", __func__, errno);
return false;
}
Метод readsome() не пытается считать данные, если их больше не доступно, поэтому флаг input.eof() никогда не будет взведен, зато можно проверить достижение конца файла по результату readsome() и выйти из цикла:
if (bytes_to_send == 0) {
break;
}
send_some(channel, &buffer[0], bytes_to_send);
}
return true;
}
Получение списка файлов в каталоге делается по-разному в зависимости от ОС. Готовая функция list_files() дана в listing.h (изменен 31.03), она работает в Windows и Linux и возвращает вектор строк-имен файлов:
std::vector<std::string> list_files();
Файл предлагается сохранить в каталог своего проекта и подключить к программе:
#include "listing.h"
Функция list_files() выдает список только обычных файлов (не скрытых, не директорий) в текущем каталоге. Гарантируется, что ни одно имя не будет длиннее 255 символов (байтов).
Список файлов получается в начале обработки запроса. Если он пуст, считается, что его по каким-то причинам не удалось получить (случай, когда рабочий каталог программы пуст, не рассматривается).
bool
serve_list(SOCKET channel) {
const auto files = list_files();
if (files.empty()) {
return send_error(channel, "unable to enumerate files");
}
Клиент принимал все содержимое сообщения за раз и разбирал его байт за байтом. Технически сервер не обязан так делать, можно отправлять длину и имя каждого файла отдельным вызовом send_some(), а потоковая природа сокета скроет это. Однако для тренировки в формировании двоичных сообщений полезнее создать ответ единым блоком body и отправить его сразу.
std::vector<uint8_t> body;
for (const auto& file : files) {
Переменная file содержит строку-имя очередного файла. К динамическому массиву body необходимо добавить один байт-длину file (типа uint8_t) и все байты строки file. Для этого необходимо увеличить длину body: новая длина равна сумме старой длины, одного байта и длины строки file.
const auto old_body_size = body.size();
body.resize(old_body_size + sizeof(uint8_t) + file.length());
После изменения размера body состоит из двух участков:
&body[0] до &body[old_body_length - 1] содержит данные, которые уже были в body до изменения размера;&body[old_body_size] и до конца предназначен для записи новых данных.Значение old_body_size необходимо было сохранить до изменения размера, после этого его уже нельзя было бы вычислить — деление массива существует только с точки зрения логики программы, а не самого массива.
Записывать данные во вторую область последовательно удобно с помощью указателя на первый из еще не использованных байтов, названный place:
uint8_t* place = &body[old_body_size];
Сначала в тот байт, на который указывает place, записывается длина имени очередного файла (функция list_files() гарантирует, что для любой из длин хватит восьми бит).
*place = file.length();
Указатель place увеличивается на количество записанных данных, т. е. на один.
place++;
Следующим шагом все символы file копируются в ту (свободную) область памяти, на которую указывает place:
std::memcpy(place, &file[0], file.length());
}
Если бы после этого требовалось бы записывать еще какие-либо данные через place, следовало бы увеличить place на количество записанных данных, т. е. на file.length().
Длина сообщения, сложенная из размера типа и размера файла, тип и содержимое файла отправляются последовательно в качестве ответа клиенту:
const uint32_t length = ::htonl(sizeof(Type) + body.size());
send_some(channel, &length, sizeof(length));
Type type = TYPE_LIST;
send_some(channel, &type, sizeof(type));
send_some(channel, &body[0], body.size());
return true;
}
Во всех заданиях нужно расширить описание протокола, поддержать изменения в клиенте и сервере и подготовить демонстрацию работы программы. Опущенные детали (тексты ошибок, типы данных и т. п.) выберите сами.
Добавьте команду /time, по которой клиент запрашивает системное время сервера сообщением нового типа 0x10. Передавать время можно как число, получаемое функцией time(), а отображать — strftime().
Добавьте к списку файлов (команда /list) их размеры в байтах, изменив формат пакета типа 0x00. Для этого можно воспользоваться усовершенствованным listing.h.
Добавьте команду /find с параметром-строкой, которая передается серверу в сообщении нового типа 0x12. Сервер должен выдать список файлов, содержащих указанную строку в имени, подобно команде /list.
Измените сервер, чтобы при запросе файла INFO, есть он или нет, выдавалось не содержимое файла, а IP-адрес и порт клиента в виде текста. Получить адрес можно getpeername(), формировать строку — stringstream.
Добавьте команду /delete и новый запрос, позволяющий удалить файл по имени. Это можно сделать DeleteFile() (Windows) или unlink() (*nix).
Добавьте команду /view, аналогичную /get, но с параметром-количеством первых байтов файла, которые пользователь желает скачать. Сервер должен выдавать не более этоно числа байтов в теле ответа.
Ограничьте количество данных (суммарный размер файлов), которые можно загрузить за одно подключение. При попытке превысить лимит сервер должен отдавать не ответ с файлом, а ответ с сообщением об ошибке (код 0xff).
Добавьте команду /stat и новый тип запроса (код 0x18), по которому сервер отдает в двоичном виде статистику: количество подключений, количество запросов на файлы и суммарный размер выгруженных данных.
Добавьте новую команду /login с двумя параметрами: именем пользователя и паролем, а также новый тип сообщения 0x19 для их передачи на сервер. Сервер должен отвечать сообщением об ошибке с требованием авторизоваться, пока не будет прислано верных учетных данных user, secret.
Добавьте отладочную команду /raw, параметры которой (одна строка) — произвольные байты, которые нужно затем отправить на сервер. (В эти байты входят 4 байта длины и 1 байт типа сообщения.) Эту строку до конца можно считать getline(), затем считать байты один за другим, как показано в примере.
Козлюк Д. А. для кафедры Управления и информатики НИУ «МЭИ», 2018 г.