В этой статье я хочу описать архитектуру своего приложения. Здесь будут представлен как графический клиент, так и сервер.

Оба приложения написаны на C. Это мой любимый язык и на нем я бы хотел писать код, если смогу когда нибудь устроиться на работу программистом. Попользовавшись ubuntu я понял что старые версии софта, это не хорошо. Например у меня несколько источников звука и микрофонов. И в ubuntu при каждой новой загрузке нужно переключать источник от наушников в источник телевизор. Также и микрофон. Как я обрадовался, что в opensuse нет с этим проблемы и доступна библиотека gtk4, которую я так давно ждал. Я мог писать приложение в gnome builder, что я иногда делаю, но в gnome builder мне не удалось сделать так, чтобы заработала библиотека ffmpeg. И как обычно я пишу в vim.

Итак. Я уже привык к объектной философии gobject и чувствую себя кофмортно в разработке. В gtk4 много изменений произошло. Например я раньше в gtk3 в text_view в сигнале "draw" выполнял функцию gtk_widget_queue_resize ();. и так получалось что это подгоняло размер под нужный, перед рисованием текста, но в gtk4 нельзя подключиться к методу отрисовки. И тогда я решил сделать свой виджет. Но свой виджет не нужно делать прям с нуля. В документации ясно сказано, что gtk_drawing_area как раз предназначен для custom виджетов.

Вот список файлов, которые учавствуют в проекте gui secure chat.

  1. assets - здесь храняться ресурсы программы. Картинки в основном, но я наверное добавлю туда еще шрифт terminus, уж очень нравиться мне этот шрифт. Текст с таким шрифтом будет смотреться очень хорошо.

  2. keys - здесь программа создает ключи для каждого пользователя. То есть есть например в сети пользователь под ником nemesis. Создается каталог nemesis. Когда я кликаю на клиенте запрос на обмен ключами, то создается два ключа, один приватный, другой публичный. Если обмен ключами произошел, то в этот каталог сохраняется еще один ключ, нужный для того, чтобы зашифровать сообщение.

  3. main.c - здесь немного логики. Я не наследуюсь от GtkApplication, а делаю как в gnome-builder, как он создает начальный код. Здесь создается приложение и окно, которое является моим наследующим виджетом от класса окна. Это сделано как бы в объектном стиле, что каждое окно, хранит в своей структуре те виджеты, которые работают и размещены в окне.

  4. main-window.c здесь главное окно. Но оно не главное. Да, в нем принимаются сообщения и обрабатываются, но это только каркас. Здесь есть виджет user-item. Это виджет, который является child для gtk_list_box. то есть участники, что видны слева, это как раз таки этот виджет под названием user-item, о котором я расскажу далее. Помимо user-item, как я писал ранее, здесь происходит обработка json данных, которые приходят от сервера. Эти данные особо не проверяют, так как подразумевается, что сервер всегда шлет правильные данные. Если мы отправим сообщение на сервер, то сервер там уже проверяет чтобы все данные были правильные и на месте, с правильными типами и т.д. Чтобы main-window виджет мог общаться по сети, ему передает с помощью g_object вроде, объект register-window.

  5. register-window.c Это класс окна, который взял на себя роль и регистрации и авторизации. В main.c этот класс создается один раз и потом просто управляется с помощью visibility. Если хочу открыть окно регистрации, то в callback функции я через properties передаю нужные строки. В итоге этот виджет создает json, где различаются только строка в "type": "register" или "type": "login". Если авторизация или регистрация прошла успешна, то передаются giostream объекты классу main-window. И далее класс main-window уже обрабатывает входящие данные.

  6. user-item.c Это класс, который является child для gtk_list_box. Но он хранит не только виджеты аватарки, никнейма и статуса online. он также создает свой scroll box, куда будут поступать новые сообщения. Он также содержит и строку ввода, то есть gtk_entry, через который можно писать сообщения. Для наглядности можно заметить, что когда нет виджетов участников, то нет и сообщений и entry виджета. Этот класс (user-item) отвечает за шифрование сообщений, передачу их, делать blink мерцание оповещение о том, что кто-то хочет обменяться с тобой ключами или blink о том, что поступило новое сообщение.

  7. message-item.c здесь проводиться показ сообщения. Это наследник от виджета drawing_area. В нем я сделал небольшую функцию, которая по заданному размеру будет отображать текст так, как это не может сделать text_view.

  8. cert.c здесь производиться работа с генерациями ключей.

это начальное окно. Слева будут участники, справа чат. Обе эти стороны объединяет виджет paned. Он хорошо справляется с задачей. Виджет тот что на header_bar слева, указывает на то, что можно скрыть левую область и не будет видно участников. справа самая правая кнопка это кнопка меню. Мне не удалось использовать реальное меню и я добавил обычный gtk_menu_button, что тоже подходит. Левее от кнопки меню - кнопка для обмена ключами.

Сначала нужно авторизоваться. Для этого выбираем из пункта меню опцию login.

После успешной авторизации, окно это скрывается и загружаются участники сервера.

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

Хочу также рассказать про серверную часть. Я всё никак не мог понять как исправить одну ошибку. Если я подключусь к серверу с помощью netcat, то сервер больше не будет принимать новых клиентов, так как сессия с netcat не закончена. Я решил написать на stackoverflow, чтобы кто нибудь дал идею. И написали через день два человека. Оба их варианта не срабатывали, но один из помощников напомнил мне о функции select и poll. Я установил таймер на две секунды и если за две секунды не будет данных, то соединение обрывается. Но все равно не срабатывало. Пробовал nonblocking сокет, но это тоже было ошибкой. потом я понял, что ssl_accept начинает обрабатывать только после 6 символов. Если ввести меньше 6 символов, то ssl_accept зависнит. Тогда я использовал функцию recv с флагов MSG_PEEK, которая не удаляла сообщение из очереди. Если в сообщение было меньше 6 символов, то соединение обрывалось. И так я решил проблему.

Сервер имеет отдельный поток. В главном потоке когда принимается новый клиент, он добавляется в epoll. И в отдельном потоке уже ждет новых сообщений. Я иногда делал серверное приложение на gio с применением шины dbus и мониторинга сетевого интерфейса, что если интернет пропадет, то отключаться все функции и закроется поток и будет ждать возобновления интернета, чтобы потом вновь все включилось и не было утечек памяти. Но в этом приложении я не стал делать пока что так. Если честно уже надоело одно и тоже, но в будущем думаю добавлю это, когда настроение будет.

Итак. Новый клиент регистрируется. Сообщение проходит этап проверок.

for (int i = 0; i < nfds; i++) {
                        struct data_client *dc = events[i].data.ptr;
                        int size = SSL_read (dc->ssl, dt, DT_SIZE);
                        if (size <= 0) {
                                char ptr[64];
                                snprintf (ptr, 64, "%lld", dc->ssl);
                                mysql_show_online_status_ptr (ptr, 0);
                                unset_to_online_table (ptr);
                                SSL_free (dc->ssl);
                                int ret = epoll_ctl (ep, EPOLL_CTL_DEL, dc->client, &ev);
                                if (ret == -1) {
                                        perror ("epoll_ctl del client");
                                }
                                close (dc->client);
                                free (dc);
                                printf ("disconnected\n");
                                continue;
                        }
                        dt[size] = 0;
                        int ret;
                        int id = 0;
                        printf ("%s\n", dt);
                        if ((ret = parse (dt, &id)) == -1) {

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

        json_object *jobj = NULL;
        int stringlen = 0;
        enum json_tokener_error jerr;
        json_tokener *tok = json_tokener_new ();
        do {
                stringlen = strlen (dt);
                jobj = json_tokener_parse_ex (tok, dt, stringlen);
        } while ((jerr = json_tokener_get_error (tok)) == json_tokener_continue);

        if (jerr != json_tokener_success) {
                fprintf (stderr, "Error tok: %s\n", json_tokener_error_desc (jerr));
                return -1;
        }

        json_object *jtype = json_object_object_get (jobj, "type");

        if (!json_object_is_type (jtype, json_type_string)) {
                json_object_put (jobj);
                fprintf (stderr, "damn. type is not string.\n");
                return -1;
        }

После этого проверяем имя типа и выполняем нужную функцию, которая укажет, все ли данные имеются.

Например возьмем проверку на регистрацию.

        if (!strncmp (type, "register", 9)) {
                int ret;
                if ((ret = mysql_registration_server (jobj)) == 0) {
                        json_object_put (jobj);
                        return -1;
                }
                *id = mysql_get_person_id (jobj);
                json_object_put (jobj);
                return ret;
        }

Функция возвращает нужный ret, если всё нормально. вот как выглядит эта функция.

int mysql_registration_server (json_object *j) {
        json_object *jlogin = json_object_object_get (j, "login");
        json_object *jpassword = json_object_object_get (j, "password");

        if (!jlogin || !jpassword) return -1;

        if (!json_object_is_type (jlogin, json_type_string)) {
                return -1;
        }
        if (!json_object_is_type (jpassword, json_type_string)) {
                return -1;
        }

        const char *login = json_object_get_string (jlogin);
        const char *password = json_object_get_string (jpassword);

        if (strlen (login) > 16) return -1;
        if (strlen (password) > 16) return -1;

        char to_login[68];
        char to_password[68];

        char query[512];
        mysql_escape_string (to_login, login, strlen (login));
        mysql_escape_string (to_password, password, strlen (password));

        snprintf (query, 512, "select * from acc where name = '%s';", to_login);
        int ret = mysql_query (mysql, query);
...
  
          if (num_fields == 0) {
                snprintf (query, 512, "insert into acc (name, password) VALUES ('%s', '%s');",
                                to_login,
                                to_password
                         );
                mysql_query (mysql, query);
                return STATUS_REGISTER;
        } else {
                return -1;
        }

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

Далее мы выходим из функции parse и идем в switch.

                        switch (ret) {
                                case STATUS_REGISTER:
                                case STATUS_LOGIN:
                                        {
                                                if (id < 0) {
                                                        json_object *buf_false = get_json_buf_false ();
                                                        const char *buf = json_object_to_json_string_ext (buf_false, JSON_C_TO_STRING_PRETTY);
                                                        SSL_write (dc->ssl, buf, strlen (buf));
                                                        json_object_put (buf_false);
                                                        SSL_free (dc->ssl);
                                                        int ret = epoll_ctl (ep, EPOLL_CTL_DEL, dc->client, &ev);
                                                        if (ret == -1) {
                                                                perror ("epoll_ctl del client");
                                                        }
                                                        close (dc->client);
                                                        free (dc);
                                                        continue;
                                                }
                                                json_object *buf_ok = get_json_buf_ok ();
                                                const char *buf = json_object_to_json_string_ext (buf_ok, JSON_C_TO_STRING_PRETTY);
                                                SSL_write (dc->ssl, buf, strlen (buf));
                                                json_object_put (buf_ok);

                                                /* get ssl ptr */
                                                char ptr[64];
                                                snprintf (ptr, 64, "%lld", dc->ssl);
                                                set_to_online_table (ptr, id);
                                                mysql_show_online_status (id, 1);
                                        }
                                        break;

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

Важный момент. Я использую snprintf (ptr = ssl);. Это решение мне понравилось. Так я могу быть уверен, что никто другой не может написать сообщение от имени другого пользователя или отправить запрос на обмен ключами от другого пользователя. В базе данных сообщения храняться только в случае, если пользователь-получатель находится в offline. Мне конечно не хотелось так делать, но все таки я считаю что это удобно, что можно оставить сообщение до поры когда участник авторизуется на сервере. Разумеется сообщение зашифровано и может быть расшифровано только участником, у которого есть приватный ключ.

В дальнейшем я хочу добавить загрузку картинок, и может быть, ну то есть возможно, что также добавлю голосовые сообщения. Так то я работал и с gstreamer и ffmpeg. посмотрим что будет удобней. Хотя что тут выбирать. Для звуковых оповещений о просьбе обменяться ключами и о новых входящих сообщениях я выберу gstreamer. Но если я захочу сделать еще и видео чат, то тогда ffmpeg, потому что я так и не смог понять как в gstreamer передавать видео, если оно не прикриплено к виджету. Да и gsteamer как я понял может только цепляться к x11 окно, у которого есть window. А ffmpeg крутая штука, я научился получать raw данные и правильно заполнять данные в формат картинки, чтобы видеть видео где хочется или передавать это по сети.

https://flathub.org/apps/details/io.github.xverizex.nem_desktop - доступно на flathub

https://github.com/xverizex/nem_desktop - исходники клиента

https://github.com/xverizex/nem-server - исходники сервера

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

Спасибо за то что заинтересовались статьей.

Комментарии (9)


  1. Armatik
    28.09.2021 10:03

    Довольно интересная система, надеюсь в будущем это перерастёт в обновлённый продукт который можно скачать и пользоваться. Будет ли поддержка мобильных платформ?


    1. xverizex Автор
      28.09.2021 10:07
      +1

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


      1. Armatik
        28.09.2021 10:38

        Идея с подставным пином очень крутая, я бы использовал эту фишку. Буду следить за развитием мессенджера. Вам удачи!


        1. xverizex Автор
          28.09.2021 10:40

          спасибо.


  1. ScarferNV
    28.09.2021 18:03

    А почему бы вместо запрашивания пароля не использовать ключ? Не думали над такой идеей?


    1. xverizex Автор
      28.09.2021 22:05

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


      1. libroten
        29.09.2021 05:46

        Но ведь переписка по идее должна привязываться к аккаунту. Есть же какой-то условный user_id. Логин или вроде того.

        Тогда, если мы входим в приложение по паре логин-пароль(ключ), то нужно расшифровывать только те переписки, что относятся к указанному user_id.


        1. xverizex Автор
          29.09.2021 05:50

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


          1. libroten
            29.09.2021 08:48

            Не знаю, зависит от Ваших целей, полагаю.

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