Skip to content

Latest commit

 

History

History
483 lines (369 loc) · 25.1 KB

lab05-tcp-server.md

File metadata and controls

483 lines (369 loc) · 25.1 KB
title lang
Лабораторная работа № 5. \ Сервер TCP для передачи файлов
ru

Цель работы

Научиться создавать серверы TCP в блокирующем режиме работы сокетов.

Задание

Реализовать сервер протокола обмена файлами из ЛР № 4.

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

Выполнение работы

(@) Создайте новый проект lab05-tcp-server, подключите необходимые библиотеки для работы с API сокетов. Для Windows инициализируйте API.

Прием подключений

Сервер TCP работает по более сложной схеме, чем клиент (см. ту же презентацию, что в ЛР № 4):

  1. Создается сокет-слушатель входящих подключений при помощи socket(). Изначально он ничем не отличается от сокета-передатчика, как в клиенте.
  2. Сокет привязывается к адресу и порту функцией bind().
  3. Сокет переводится в режим слушателя функцией listen().
  4. Входящее подключение принимается функцией accept(). При этом создается сокет-передатчик для данных принятого соединения.
  5. Через сокет-передатчик ведется обмен данными с удаленным узлом стандартными фнукциями send() и recv().
  6. Сокет-передатчик закрывается функцией closesocket() или close().
  7. Сокет-слушатель может принять новое подключение, вернувшись к пункту 4; либо оо может быть закрыт вызовом 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);
    ```
* Запустите программу, привяжите ее к адресу 127.0.0.1 и порту 1234.
* При помощи 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()][cppref/strftime].

[cppref/strftime]: http://en.cppreference.com/w/cpp/chrono/c/strftime

#. Добавьте к списку файлов (команда /list) их размеры в байтах, изменив формат пакета типа 0x00. Для этого можно воспользоваться усовершенствованным listing.h.

#. Добавьте команду /find с параметром-строкой, которая передается серверу в сообщении нового типа 0x12. Сервер должен выдать список файлов, содержащих указанную строку в имени, подобно команде /list.

#. Измените сервер, чтобы при запросе файла INFO, есть он или нет, выдавалось не содержимое файла, а IP-адрес и порт клиента в виде текста. Получить адрес можно [getpeername()][msdn/getpeername], формировать строку — stringstream.

[msdn/getpeername]: https://msdn.microsoft.com/en-us/library/windows/desktop/ms738533(v=vs.85).aspx

#. Добавьте команду /delete и новый запрос, позволяющий удалить файл по имени. Это можно сделать [DeleteFile()][msdn/DeleteFile] (Windows) или [unlink()][man/unlink] (*nix).

[msdn/DeleteFile]: https://msdn.microsoft.com/en-us/library/windows/desktop/aa363915(v=vs.85).aspx
[man/unlink]: https://linux.die.net/man/2/unlink

#. Добавьте команду /view, аналогичную /get, но с параметром-количеством первых байтов файла, которые пользователь желает скачать. Сервер должен выдавать не более этоно числа байтов в теле ответа.

#. Ограничьте количество данных (суммарный размер файлов), которые можно загрузить за одно подключение. При попытке превысить лимит сервер должен отдавать не ответ с файлом, а ответ с сообщением об ошибке (код 0xff).

#. Добавьте команду /stat и новый тип запроса (код 0x18), по которому сервер отдает в двоичном виде статистику: количество подключений, количество запросов на файлы и суммарный размер выгруженных данных.

#. Добавьте новую команду /login с двумя параметрами: именем пользователя и паролем, а также новый тип сообщения 0x19 для их передачи на сервер. Сервер должен отвечать сообщением об ошибке с требованием авторизоваться, пока не будет прислано верных учетных данных user, secret.

#. Добавьте отладочную команду /raw, параметры которой (одна строка) — произвольные байты, которые нужно затем отправить на сервер. (В эти байты входят 4 байта длины и 1 байт типа сообщения.) Эту строку до конца можно считать getline(), затем считать байты один за другим, как показано [в примере][line-parser].

[line-parser]: /study/courses/sdt/17/lecture03_structured-program.slides.pdf#page=28