From d7006aecb5954a164f57885a5dbbfc95f19ce7b7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 2 Nov 2026 18:47:58 +0100 Subject: [PATCH] Add signed device groups and compact NODEINFO packing --- doc/devgroup_tz.txt | 105 ++++--- doc/node_snapshot.md | 24 ++ src/Makefile.am | 10 + src/chat/chat_core.c | 8 + src/chat/chat_core_priv.h | 2 + src/chat/chat_devgroup.c | 203 ++++++++++++ src/chat/chat_devgroup.h | 49 +++ src/chat/chat_devgroup_invite.c | 282 +++++++++++++++++ src/chat/chat_event.c | 4 +- src/chat/chat_event.h | 2 + src/chat/chat_headless_control.c | 28 ++ src/chat/chat_member.c | 10 +- src/chat/chat_member.h | 14 +- src/routing_layer/devgroup.c | 293 ++++++++++++++++++ src/routing_layer/devgroup.h | 57 ++++ src/routing_layer/topo_group.c | 2 +- src/routing_layer/topo_node.c | 48 ++- src/routing_layer/topo_node.h | 12 +- src/routing_layer/topo_node_sqlite.c | 4 + src/transport_layer/etcp_api.h | 1 + src/utun_instance.c | 5 +- tests/Makefile.am | 9 + tests/test_chat_headless_control.c | 8 + tests/test_devgroup.c | 73 +++++ tests/test_devgroup_network.c | 212 +++++++++++++ tests/test_topo_node_wire.c | 31 +- tools/chatcli | 13 + tools/chatcli_commands.txt | 11 + .../java/com/utun/chat/ChatApplication.kt | 3 +- .../java/com/utun/chat/data/ChatRepository.kt | 16 +- .../com/utun/chat/data/DevgroupRepository.kt | 41 +++ .../com/utun/chat/data/MemberRepository.kt | 1 + .../main/java/com/utun/chat/data/NativeLib.kt | 3 + .../utun/chat/ui/screens/DevgroupDialog.kt | 52 ++++ .../utun/chat/ui/screens/MemberListScreen.kt | 37 ++- .../app/src/main/res/values-ru/strings.xml | 17 + .../app/src/main/res/values/strings.xml | 17 + .../utun/chat/data/MemberRepositoryTest.kt | 10 + .../jni_bridge/android_jni_bridge.c | 20 ++ tools/chatgui/CMakeLists.txt | 3 +- tools/chatgui/src/accountdelegate.cpp | 8 + tools/chatgui/src/accountlist.cpp | 37 ++- tools/chatgui/src/accountlist.h | 2 + tools/chatgui/src/devgroupdialog.cpp | 116 +++++++ tools/chatgui/src/devgroupdialog.h | 11 + tools/chatgui/src/memberlistmodel.cpp | 53 ++++ tools/chatgui/src/memberlistmodel.h | 16 + tools/chatgui/tests/test_member_detail.cpp | 24 ++ tools/chatgui/translations/chatgui_en.ts | 28 ++ tools/chatgui/translations/chatgui_ru.ts | 28 ++ tools/chatgui/transport/gui_bridge.h | 1 + tools/chatgui/transport/gui_bridge_impl.cpp | 9 + 52 files changed, 1979 insertions(+), 94 deletions(-) create mode 100644 src/chat/chat_devgroup.c create mode 100644 src/chat/chat_devgroup.h create mode 100644 src/chat/chat_devgroup_invite.c create mode 100644 src/routing_layer/devgroup.c create mode 100644 src/routing_layer/devgroup.h create mode 100644 tests/test_devgroup.c create mode 100644 tests/test_devgroup_network.c create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt create mode 100644 tools/chatgui/src/devgroupdialog.cpp create mode 100644 tools/chatgui/src/devgroupdialog.h diff --git a/doc/devgroup_tz.txt b/doc/devgroup_tz.txt index 2a098db8a..7ad5d2ad2 100644 --- a/doc/devgroup_tz.txt +++ b/doc/devgroup_tz.txt @@ -1,48 +1,57 @@ -Внедрение группы устройств в чат (devgroup). - -Первый этап: объединение устройств одного пользователя и группировка в списке участников. - -1. Идентичность и хранение. -Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей: -X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу. -Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи. -Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи). -Одного совпадения публичного ключа devgroup недостаточно. -Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте. - -2. Приглашение. -A выбирает известное устройство B и показывает PIN из 4 цифр. B получает приглашение и вводит PIN с экрана A. -Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут. -Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки. -После проверки A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех. -Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются. -Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет. - -3. Профиль и версии. -Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup -с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы. -Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)). -Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается. -Формат должен учитывать текущие ограничения userinfo: строковые значения и общий размер 1024 байта вместе с остальными свойствами. - -4. Репликация первого этапа. -Оба публичных ключа, удостоверение устройства и подписанный профиль передаются в подписанном userinfo участника. -Устройство публикует их во всех своих каналах. Более свежий проверенный профиль своей devgroup принимает -и переносит в собственные записи остальных каналов, не меняя чужие записи и не перепубликуя неизменившиеся данные. -Это работает по связанной цепочке общих каналов; независимая от каналов сеть устройств в первый этап не входит. -При создании канала и вступлении в новый канал сразу публикуется актуальная принадлежность. - -5. Выход. -Добровольный выход удаляет локальные ключи и devgroup-поля из всех собственных member-записей с повышением их версий. -Локальное состояние и публикации меняются атомарно; уведомления и распространение выполняются после commit. -Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап. - -6. Список участников и границы этапа. -В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup. -Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств; -заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство. -Звонки на все устройства и объединение DM в первый этап не входят. - -Порядок реализации: состояние и проверка devgroup → профиль и репликация → приглашения → API/headless → Qt и Android. -Проверить подделки, конфликты версий, offline-сходимость, повторы, потери ответов, перезапуск, ошибки сохранения и выход. -Логи показывают состояния и причины отказов, но не PIN и секреты. +Внедрение группы устройств в чат (devgroup). + +Первый этап: объединение устройств одного пользователя и группировка в списке участников. + +1. Идентичность и хранение. +Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей: +X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу. +Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи. +Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи). +Одного совпадения публичного ключа devgroup недостаточно. +Публичные данные devgroup хранятся в отдельных полях nodes: оба публичных ключа, удостоверение устройства, JSON профиля и его подпись. +Эти же данные входят в TOPO_NODE и подписанный снимок node_snapshots; nodes содержит их согласованную проекцию. +Принадлежность общая для узла, независимо от каналов; в peers_ и userinfo поля devgroup не добавляются. +Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте. + +2. Приглашение. +A выбирает известное устройство B и показывает PIN из 4 цифр. B получает приглашение и вводит PIN с экрана A. +Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут. +Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки. +После проверки A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех. +Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются. +Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет. + +3. Профиль и версии. +Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup +с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы. +Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)). +Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается. +Для профиля devgroup задаётся отдельный ограниченный размер с учётом полного NODEINFO; лимит userinfo менять не требуется. +Версия профиля devgroup и timestamp подписанного снимка узла независимы. + +4. Репликация первого этапа. +Данные devgroup распространяются через текущую синхронизацию nodes (NODEINFO/BGP) в составе общей подписанной записи узла. +Текущих гарантий доставки достаточно для первого этапа; отдельный протокол синхронизации и offline-доставки не добавляется. +Проверяются подпись узла, удостоверение принадлежности и подпись профиля devgroup. Обновление снимка заменяет блок целиком. +Более свежий профиль своей devgroup устройство принимает и публикует в новом собственном снимке во всех своих топологических группах. +Чужие снимки не меняются; неизменившийся профиль не вызывает новой публикации. +Синхронизация зависит от распространения NODEINFO через существующие группы; отдельная сеть устройств в первый этап не входит. +После восстановления связи и при вступлении в новую группу используется штатный обмен NODEINFO. + +5. Выход. +Добровольный выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp. +Локальное состояние, снимок node_snapshots и поля nodes меняются атомарно; уведомления и распространение выполняются после commit. +Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап. + +6. Список участников и границы этапа. +В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup. +Принадлежность берётся из nodes по node_id; профиль выбирается простым поиском наиболее свежего проверенного объявления +среди текущих записей известных узлов devgroup по порядку из п. 3. Отдельный постоянный кеш профилей не нужен. +Изменение общей записи обновляет отображение участника во всех каналах, где он присутствует. +Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств; +заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство. +Звонки на все устройства и объединение DM в первый этап не входят. + +Порядок реализации: nodes/TOPO_NODE/снимок и проверка devgroup → NODEINFO/BGP и профиль → приглашения → API/headless → Qt и Android. +Проверить подделки, конфликты версий, обновление через штатный обмен NODEINFO, повторы, потери ответов, перезапуск, ошибки сохранения и выход. +Логи показывают состояния и причины отказов, но не PIN и секреты. diff --git a/doc/node_snapshot.md b/doc/node_snapshot.md index b2683cb8e..daa3116f2 100644 --- a/doc/node_snapshot.md +++ b/doc/node_snapshot.md @@ -167,3 +167,27 @@ member-записи не должны восстанавливать намер REALITY использует полное SNI до 255 символов; размер поля в записи узла согласован с транспортом. Если локального TCP listen-сокета нет, NCD может создать исходящий TCP-линк с выбором локального адреса операционной системой. + +Devgroup входит в общую подписанную запись: Ed25519/X25519 public keys, +удостоверение данного устройства, подпись профиля и исходные байты JSON. +В `nodes` хранятся `devgroup_ed`, `devgroup_x`, `devgroup_member_sig`, +`devgroup_json`, `devgroup_profile_sig`; отсутствие группы — NULL во всех пяти полях. +Проекция меняется вместе с `node_snapshots` в одной транзакции. Локальные секреты +находятся в `devgroup_local`, квитанции завершённого импорта — в `devgroup_receipts`. +Квитанции сохраняются после выхода: повтор передачи не восстанавливает членство. +Выбор имени: максимум (ver,timestamp,SHA256(JSON)) среди проверенных объявлений `nodes`. + +NODEINFO упакован без выравнивания структур. Заголовок содержит uint16 +`reality_size` и `devgroup_size`, затем прежние поля. После заголовка идут +`node_name_len` байт имени (без NUL), `devgroup_size` байт devgroup, metadata и адреса, +REALITY, опциональные подсети и список переходов. Пустой devgroup занимает 0 байт тела. +Непустой блок: Ed32, X32, удостоверение64, подпись JSON64, JSON без NUL (до768 байт). + +REALITY: socket_id1, short_id8, server_pubkey32, version3, server_name_len1, +затем ровно server_name_len байт SNI без NUL. Прежний char[256] на проводе удалён. +Для SNI длиной N экономия составляет 255−N байт на сокет; `reality_size` добавляет +2 байта один раз в заголовке. Например, для example.com экономия244 байта на сокет. +Имя самого узла уже передавалось переменной длиной; его упаковка сохранена. +Парсер проверяет длину секции, число записей и отсутствие NUL внутри переданного имени. +Локальный GUI/JNI-снимок участника имеет свой формат, описанный в `chat_member.h`; +он не передаётся в BGP и не является NODEINFO. diff --git a/src/Makefile.am b/src/Makefile.am index 92f79e214..963e68b16 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -53,6 +53,8 @@ utun_CORE_SOURCES = \ routing_layer/route_ping.c \ routing_layer/nat_detection.c \ routing_layer/topo_node.c \ + routing_layer/devgroup.c \ + routing_layer/devgroup.h \ routing_layer/topo_node_sqlite.c \ routing_layer/route_connectivity.c \ routing_layer/sock_match.c \ @@ -183,6 +185,9 @@ utun_CORE_SOURCES = \ chat/chat_whisper.c \ chat/chat_channel.c \ chat/chat_profile.c \ + chat/chat_devgroup.c \ + chat/chat_devgroup_invite.c \ + chat/chat_devgroup.h \ chat/group_avatar.c \ chat/chat_status.c \ chat/chat_sync.c \ @@ -250,6 +255,8 @@ libutun_a_SOURCES = \ routing_layer/route_ping.c \ routing_layer/nat_detection.c \ routing_layer/topo_node.c \ + routing_layer/devgroup.c \ + routing_layer/devgroup.h \ routing_layer/topo_node_sqlite.c \ routing_layer/route_connectivity.c \ routing_layer/sock_match.c \ @@ -378,6 +385,9 @@ libutun_a_SOURCES = \ chat/chat_poll.h \ chat/chat_channel.c \ chat/chat_profile.c \ + chat/chat_devgroup.c \ + chat/chat_devgroup_invite.c \ + chat/chat_devgroup.h \ chat/group_avatar.c \ chat/chat_status.c \ chat/chat_sync.c \ diff --git a/src/chat/chat_core.c b/src/chat/chat_core.c index f4879cc57..83a187f83 100644 --- a/src/chat/chat_core.c +++ b/src/chat/chat_core.c @@ -11,6 +11,7 @@ */ #include "chat_core_priv.h" +#include "chat_devgroup.h" #include "chat_whisper.h" #include "chat_attachment.h" #include "group_avatar.h" @@ -240,6 +241,7 @@ void chat_core_destroy(struct UTUN_INSTANCE* inst) { struct chat_core_ctx* cc = inst ? CC(inst) : NULL; if (!cc || !cc->initialized) return; cc->initialized = 0; + chat_devgroup_destroy(inst); chat_whisper_destroy(inst); chat_member_destroy(inst); chat_attachment_destroy(inst); @@ -523,12 +525,18 @@ int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, ch const uint8_t* key = sqlite3_column_blob(st, i + 2); if (!key) { rc = SQLITE_NOMEM; goto error; } for (int b = 0; b < 32; b++) snprintf(keys[i] + b * 2, 3, "%02x", key[b]); } + uint8_t dg[32]; char dg_name[64], dg_hex[65] = ""; + int dg_found = devgroup_member(cc->db, node, dg, dg_name); + if (dg_found < 0) { stage = "devgroup"; rc = SQLITE_CORRUPT; goto error; } + if (dg_found) for (int i = 0; i < 32; i++) snprintf(dg_hex + 2*i, 3, "%02x", dg[i]); int online = !sqlite3_column_int(st, 5) && chat_core_member_online(inst, ch_id, node); int connected = inst->connections && queue_find_data_by_index(inst->connections, (const uint8_t*)&node); stage = "JSON"; rc = SQLITE_NOMEM; if (strbuf_addf(&json, "%s{\"node_id\":\"0x%016llx\",\"name\":", json.len > 1 ? "," : "", (unsigned long long)node) < 0 || strbuf_add_json_string(&json, name) < 0 || strbuf_addf(&json, ",\"online\":%d,\"connected\":%d,\"adm_tags\":", online, connected) < 0 || strbuf_add_json_string(&json, tags) < 0 || + strbuf_addf(&json, ",\"devgroup\":\"%s\",\"devgroup_name\":", dg_hex) < 0 || + strbuf_add_json_string(&json, dg_name) < 0 || strbuf_addf(&json, ",\"x25519\":\"%s\",\"ed25519\":\"%s\",\"addrs\":[", keys[0], keys[1]) < 0) goto error; stage = "addresses prepare"; rc = sqlite3_prepare_v2(cc->db, "SELECT family,protocol,address,port,rtt FROM node_addresses WHERE node_id=? ORDER BY family,protocol", diff --git a/src/chat/chat_core_priv.h b/src/chat/chat_core_priv.h index 6e9f7d79c..47ac92ef8 100644 --- a/src/chat/chat_core_priv.h +++ b/src/chat/chat_core_priv.h @@ -93,7 +93,9 @@ struct ak_file; struct chat_whisper; struct member_route_event; +struct chat_devgroup; struct chat_core_ctx { + struct chat_devgroup* devgroup; /* Приглашения, retry и периодическое принятие профиля из nodes. */ struct UTUN_INSTANCE* inst; /* Заимствованный владелец;не уничтожается из callbacks чата. */ sqlite3* db; /* Заимствована у ядра;chat_core её не открывает/не закрывает. */ char db_path[512]; /* Копия пути init для идентичности запуска/default media base;не отдельное соединение БД. */ diff --git a/src/chat/chat_devgroup.c b/src/chat/chat_devgroup.c new file mode 100644 index 000000000..f4522c1db --- /dev/null +++ b/src/chat/chat_devgroup.c @@ -0,0 +1,203 @@ +#include "chat_devgroup.h" +#include "chat_core_priv.h" +#include "chat_member.h" +#include "../utun_instance.h" +#include "../routing_layer/topo_group.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../transport_layer/secure_channel.h" +#include "../ntp_time.h" +#include "../../lib/mem.h" +#include "../../lib/strbuf.h" +#include +#include + +static int cd_sql(sqlite3* db, const char* sql) { + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: SQL rc=%d: %s", rc, sqlite3_errmsg(db)); + return rc == SQLITE_OK ? 0 : -1; +} +static int cd_finish(sqlite3* db, sqlite3_stmt* st, int rc, int expected) { + int final = sqlite3_finalize(st); + if (rc == expected && final == SQLITE_OK) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: SQL rc=%d expected=%d finalize=%d: %s", rc, expected, final, sqlite3_errmsg(db)); + return -1; +} +/* Отложенную доставку снимков выполняет штатная BGP-очередь. */ +static void cd_publish(struct UTUN_INSTANCE* inst) { + for (struct ll_entry* e = inst->topo_groups->group_list->head; e; e = e->next) { + struct TOPO_GROUP* g = (struct TOPO_GROUP*)e; + if (!g->local_node || !g->senders_list) continue; + for (struct ll_entry* s = g->senders_list->head; s; s = s->next) { + struct TOPO_GROUP_CONN_ITEM* peer = (struct TOPO_GROUP_CONN_ITEM*)s->data; + if (peer && peer->conn) topo_group_send_nodeinfo(g, g->local_node, peer->conn); + } + } + /* Снимок self мог изменить заголовок всей devgroup, поэтому обновляем полные списки. */ + chat_event_post(inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0); +} +int chat_devgroup_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[16], uint64_t peer) { + sqlite3* db = inst ? inst->topo_sqlite_db : NULL; sqlite3_stmt* st = NULL; + if (!db || !receipt || !peer) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid receipt query"); return -1; } + int rc = sqlite3_prepare_v2(db, "SELECT peer FROM devgroup_receipts WHERE node_id=? AND id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, receipt, 16, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int found = rc == SQLITE_ROW; + if (found) rc = (uint64_t)sqlite3_column_int64(st, 0) == peer ? SQLITE_DONE : SQLITE_CONSTRAINT; + if (cd_finish(db, st, rc, SQLITE_DONE)) return -1; + return found; +} +static int cd_commit(struct UTUN_INSTANCE* inst, const struct devgroup_record* r, + const uint8_t ep[32], const uint8_t xp[32], const uint8_t receipt[16], uint64_t peer) { + sqlite3* db = inst ? inst->topo_sqlite_db : NULL; + struct TOPO_NODE* current = inst ? topo_node_registry_find(inst->topo_groups, inst->node_id) : NULL; + if (!db || !current || !sqlite3_get_autocommit(db) || + devgroup_verify(r, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid commit context/record"); return -1; + } + struct TOPO_NODE next = *current; next.devgroup = *r; next.ver = (current->ver % 255) + 1; + if (cd_sql(db, "BEGIN IMMEDIATE")) return -1; + sqlite3_stmt* st = NULL; int rc; + if (r->json[0]) { + uint8_t wire[DEVGROUP_WIRE_MAX]; int n = devgroup_encode(r, wire, sizeof(wire)); + if (n <= 0) goto rollback; + rc = sqlite3_prepare_v2(db, "INSERT INTO devgroup_local(node_id,ed_priv,x_priv,record) VALUES(?,?,?,?)" + " ON CONFLICT(node_id) DO UPDATE SET ed_priv=excluded.ed_priv,x_priv=excluded.x_priv,record=excluded.record", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, ep, 32, SQLITE_TRANSIENT); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, xp, 32, SQLITE_TRANSIENT); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 4, wire, n, SQLITE_TRANSIENT); + } else { + rc = sqlite3_prepare_v2(db, "DELETE FROM devgroup_local WHERE node_id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + } + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int failed = cd_finish(db, st, rc, SQLITE_DONE); st = NULL; + if (failed || topo_node_sign_self(inst, &next)) goto rollback; + if (receipt) { + rc = sqlite3_prepare_v2(db, "INSERT INTO devgroup_receipts(node_id,id,peer) VALUES(?,?,?)", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, receipt, 16, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)peer); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + failed = cd_finish(db, st, rc, SQLITE_DONE); st = NULL; if (failed) goto rollback; + } + if (cd_sql(db, "COMMIT")) goto rollback; + *current = next; + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: committed node=%016llx member=%d timestamp=%llu", + (unsigned long long)inst->node_id, !!r->json[0], (unsigned long long)next.timestamp); + cd_publish(inst); return 0; +rollback: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: operation rolled back"); + if (!sqlite3_get_autocommit(db) && cd_sql(db, "ROLLBACK")) utun_instance_stop(inst); + return -1; +} +/* Подписать удостоверение данного устройства, не меняя подписанный JSON профиля. */ +static int cd_rebind(struct UTUN_INSTANCE* inst, struct devgroup_record* record, const uint8_t ep[32]) { + return devgroup_bind(record, ep, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey); +} +int chat_devgroup_refresh(struct UTUN_INSTANCE* inst) { + struct devgroup_record local, best; struct devgroup_profile lp, bp; uint8_t ep[32], xp[32]; + int rc = devgroup_load_local(inst, &local, ep, xp); + if (rc <= 0) goto done; + rc = devgroup_latest(inst->topo_sqlite_db, local.ed, &best, &bp); + if (rc <= 0) goto done; + if (devgroup_profile_read(&local, &lp)) { rc = -1; goto done; } + if (devgroup_profile_compare(&bp, &lp) <= 0) { rc = 0; goto done; } + if (memcmp(local.x, best.x, 32)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: encryption key change rejected"); rc = -1; goto done; } + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: adopt newer profile version=%llu->%llu timestamp=%llu", + (unsigned long long)lp.version, (unsigned long long)bp.version, (unsigned long long)bp.timestamp); + rc = cd_rebind(inst, &best, ep); + if (!rc) rc = cd_commit(inst, &best, ep, xp, NULL, 0); +done: + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); return rc < 0 ? -1 : 0; +} +int chat_devgroup_rename(struct UTUN_INSTANCE* inst, const char* name) { + struct devgroup_record r; struct devgroup_profile p = {0}; uint8_t ep[32], xp[32]; + if (chat_devgroup_refresh(inst)) return -1; + int rc = devgroup_load_local(inst, &r, ep, xp); if (rc < 0) goto done; + if (rc) { + if (devgroup_profile_read(&r, &p)) { rc = -1; goto done; } + if (name && !strcmp(name, p.name)) { rc = 0; goto done; } + } else if (RAND_bytes(ep, 32) != 1 || RAND_bytes(xp, 32) != 1 || devgroup_public_keys(ep, xp, r.ed, r.x)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: key generation failed"); rc = -1; goto done; + } + time_t now = ntp_time_get_seconds(inst); + rc = devgroup_sign(&r, ep, name, p.version + 1, now > 0 ? (uint64_t)now : 1, + inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey); + if (!rc) rc = cd_commit(inst, &r, ep, xp, NULL, 0); +done: + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); return rc; +} +int chat_devgroup_leave(struct UTUN_INSTANCE* inst) { + struct devgroup_record r = {0}; uint8_t ep[32], xp[32]; + int found = devgroup_load_local(inst, &r, ep, xp); + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); + if (found < 0) return -1; + int rc = 0; + if (found) { memset(&r, 0, sizeof(r)); rc = cd_commit(inst, &r, NULL, NULL, NULL, 0); } + if (!rc) chat_devgroup_cancel_pending(inst); + return rc; +} +int chat_devgroup_import(struct UTUN_INSTANCE* inst, const struct devgroup_record* input, + const uint8_t ep[32], const uint8_t xp[32], const uint8_t receipt[16], uint64_t peer) { + if (!input || !ep || !xp || !receipt) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid import arguments"); return -1; } + int done = chat_devgroup_receipt(inst, receipt, peer); if (done) return done > 0 ? 0 : -1; + struct devgroup_record local, r = *input; uint8_t old_e[32], old_x[32], ed[32], x[32]; struct devgroup_profile p; + int found = devgroup_load_local(inst, &local, old_e, old_x); + OPENSSL_cleanse(old_e, 32); OPENSSL_cleanse(old_x, 32); + if (found < 0 || (found && (memcmp(local.ed, r.ed, 32) || memcmp(local.x, r.x, 32)))) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: import into another group rejected"); return -1; + } + if (devgroup_public_keys(ep, xp, ed, x) || memcmp(ed, r.ed, 32) || memcmp(x, r.x, 32) || devgroup_profile_read(&r, &p)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: imported keys/profile mismatch"); return -1; + } + if (found) { struct devgroup_profile own; if (devgroup_profile_read(&local, &own)) return -1; + if (devgroup_profile_compare(&own, &p) > 0) r = local; + } + if (cd_rebind(inst, &r, ep)) return -1; + return cd_commit(inst, &r, ep, xp, receipt, peer); +} + +int chat_devgroup_execute(const struct chat_devgroup_request* req, char** json) { + if (!json) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: missing command result"); return -1; } + *json = NULL; + if (!req || !req->inst || !memchr(req->text, 0, sizeof(req->text)) || !memchr(req->pin, 0, sizeof(req->pin))) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid command"); return -1; + } + int rc = -1; + switch (req->action) { + case CHAT_DEVGROUP_STATUS: break; + case CHAT_DEVGROUP_INVITE: { + char id[33], pin[5]; struct strbuf result = {0}; + rc = chat_devgroup_invite(req->inst, req->group, req->peer, id, pin); + if (!rc && strbuf_addf(&result, "{\"id\":\"%s\",\"pin\":\"%s\"}", id, pin) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invite result allocation failed"); rc = -1; + } + OPENSSL_cleanse(pin, sizeof(pin)); + if (!rc) *json = strbuf_detach(&result); + strbuf_free(&result); return rc; + } + case CHAT_DEVGROUP_ACCEPT: rc = chat_devgroup_accept(req->inst, req->text, req->pin); break; + case CHAT_DEVGROUP_RENAME: rc = chat_devgroup_rename(req->inst, req->text); break; + case CHAT_DEVGROUP_LEAVE: rc = chat_devgroup_leave(req->inst); break; + default: DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: unknown action=%d", req->action); return -1; + } + if (req->action != CHAT_DEVGROUP_STATUS && rc) return -1; + *json = chat_devgroup_status(req->inst); + return *json ? 0 : -1; +} +void chat_devgroup_trampoline(void* arg) { + struct chat_devgroup_request* req = arg; + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: missing request"); return; } + char* json = NULL; int rc = chat_devgroup_execute(req, &json); + size_t n = json ? strlen(json) : 0; uint8_t* event = u_malloc(9 + n); + if (event) { + for (unsigned i = 0; i < 8; i++) event[i] = (uint8_t)(req->request_id >> (8 * i)); + event[8] = !rc; if (n) memcpy(event + 9, json, n); + chat_event_post(req->inst, CHAT_EVT_DEVGROUP_RESULT, event, 9 + n); + OPENSSL_cleanse(event, 9 + n); u_free(event); + } else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: result event allocation failed"); + if (json) { OPENSSL_cleanse(json, n); u_free(json); } + OPENSSL_cleanse(req, sizeof(*req)); u_free(req); +} diff --git a/src/chat/chat_devgroup.h b/src/chat/chat_devgroup.h new file mode 100644 index 000000000..56b8208cf --- /dev/null +++ b/src/chat/chat_devgroup.h @@ -0,0 +1,49 @@ +/* chat_devgroup — управление общей группой устройств в потоке uasync. + * Публичная принадлежность живёт в ядре; чат управляет приглашениями и локальными изменениями. + * Все результаты операций 0/-1, события после commit. Аргументы заимствованы до возврата. */ +#ifndef CHAT_DEVGROUP_H +#define CHAT_DEVGROUP_H +#include "../routing_layer/devgroup.h" +#ifdef __cplusplus +extern "C" { +#endif +enum chat_devgroup_action { CHAT_DEVGROUP_STATUS, CHAT_DEVGROUP_INVITE, CHAT_DEVGROUP_ACCEPT, CHAT_DEVGROUP_RENAME, CHAT_DEVGROUP_LEAVE }; +/* Запрос UI: text=имя/ID приглашения, pin только для ACCEPT; group/peer только для INVITE. + * Все массивы NUL-terminated. Трамплин забирает u_malloc-блок; RESULT=request_id LE8,success1,JSON UTF-8. + * JSON результата INVITE содержит PIN: показывать только локально, не логировать. */ +struct chat_devgroup_request { + struct UTUN_INSTANCE* inst; + uint64_t request_id, group, peer; + enum chat_devgroup_action action; + char text[128], pin[5]; +}; +/* Синхронно в uasync:0/-1, *json caller освобождает u_free; при успехе всегда JSON. */ +int chat_devgroup_execute(const struct chat_devgroup_request* req, char** json); +void chat_devgroup_trampoline(void* arg); +/* Сервис приглашений: init0/-1, destroy отменяет ожидания; локальная принадлежность остаётся в ядре. */ +int chat_devgroup_init(struct UTUN_INSTANCE* inst); +/* После локального выхода отменить ожидания; уже переданные ключи имеют неизвестный результат. */ +void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst); +void chat_devgroup_destroy(struct UTUN_INSTANCE* inst); +/* Пригласить известный узел через CHAT-группу; out_id[33] hex, out_pin[5]. 0/-1, PIN только для локального UI. */ +int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]); +/* Ввести PIN для входящего приглашения; id — 32 hex, pin — ровно четыре цифры. 0/-1. */ +int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char* pin); +/* Снимок группы/приглашений в JSON; caller освобождает через u_free. PIN/секретов в снимке нет. */ +char* chat_devgroup_status(struct UTUN_INSTANCE* inst); +/* Создать группу либо переименовать свою. Имя UTF-8,1..63 байта; no-op не повышает версию. */ +int chat_devgroup_rename(struct UTUN_INSTANCE* inst, const char* name); +/* Выйти: удалить секреты и опубликовать пустой блок в одной транзакции. Повтор допустим. */ +int chat_devgroup_leave(struct UTUN_INSTANCE* inst); +/* Принять профиль из nodes и опубликовать только при увеличении версии; секреты не устанавливает. */ +int chat_devgroup_refresh(struct UTUN_INSTANCE* inst); +/* Импорт от протокола приглашения: ключи проверяются, смешение разных групп запрещено. + * receipt[16] и peer связывают результат с приглашением; повтор никогда не восстанавливает удалённые ключи. */ +int chat_devgroup_import(struct UTUN_INSTANCE* inst, const struct devgroup_record* record, + const uint8_t ed_priv[32], const uint8_t x_priv[32], const uint8_t receipt[16], uint64_t peer); +/* Прочитать сохранённый результат приглашения:1=импорт уже выполнен,0=нет,-1=ошибка/другой peer. */ +int chat_devgroup_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[16], uint64_t peer); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/chat/chat_devgroup_invite.c b/src/chat/chat_devgroup_invite.c new file mode 100644 index 000000000..95e5c00a9 --- /dev/null +++ b/src/chat/chat_devgroup_invite.c @@ -0,0 +1,282 @@ +#include "chat_devgroup.h" +#include "chat_core_priv.h" +#include "chat_event.h" +#include "member_sync.h" +#include "../utun_instance.h" +#include "../routing_layer/route_crypto.h" +#include "../transport_layer/etcp.h" +#include "../../lib/mem.h" +#include "../../lib/u_async.h" +#include "../../lib/strbuf.h" +#include +#include + +#define DG_SLOTS 16 +#define DG_TTL 1200000ULL +#define DG_RETRY 30000ULL +#define DG_OFFER 1 +#define DG_PIN 2 +#define DG_KEYS 3 +#define DG_RESULT 4 +/* state: 1=ожидается PIN,2=отправлен PIN,3=переданы ключи,4=успех,5=отказ,6=истёк,7=результат неизвестен. */ +struct dg_invite { + uint8_t id[16], x[32], ed[32], group_ed[32]; + uint64_t group, peer, expires, sent; + unsigned state, outgoing, attempts; + uint8_t last_pin[4], last_result; /* Повторы одной попытки получают прежний результат. */ + char pin[5]; /* Только локальная память; никогда не выводится в журнал. */ +}; +struct chat_devgroup { + struct UTUN_INSTANCE* inst; + struct dg_invite slots[DG_SLOTS]; + void* timer; + unsigned ticks; +}; +static struct chat_devgroup* dg_ctx(struct UTUN_INSTANCE* inst) { return inst && CC(inst) ? CC(inst)->devgroup : NULL; } +static void dg_hex(const uint8_t* in, size_t n, char* out) { for (size_t i = 0; itopo_groups, peer); + if (!node || peer == inst->node_id || member_sync_verified_ed25519(inst, channel, peer, ed) || memcmp(ed, node->ed25519_public_key, 32)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer identity unavailable group=%llu peer=%016llx", (unsigned long long)group, (unsigned long long)peer); + return -1; + } + memcpy(x, node->public_key, 32); return 0; +} +static struct dg_invite* dg_find(struct chat_devgroup* ctx, const uint8_t id[16]) { + for (unsigned i = 0; islots[i].state && !memcmp(ctx->slots[i].id, id, 16)) return &ctx->slots[i]; + return NULL; +} +static struct dg_invite* dg_slot(struct chat_devgroup* ctx, uint64_t peer) { + struct dg_invite* free_slot = NULL; + for (unsigned i = 0; islots[i]; + if (p->state && p->state<4 && p->peer == peer) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer already has pending invitation"); return NULL; } + if (!p->state || (p->state >= 4 && get_time_tb()>p->expires)) free_slot = p; + } + if (!free_slot) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation capacity reached"); + if (free_slot) OPENSSL_cleanse(free_slot, sizeof(*free_slot)); + return free_slot; +} +static int dg_send(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t kind, const uint8_t* body, size_t n) { + uint8_t x[32], ed[32]; + p->sent = get_time_tb(); + if (dg_peer(ctx->inst, p->group, p->peer, x, ed) || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation peer keys changed"); return -1; + } + struct ll_entry* entry = ll_alloc_lldgram(18 + n); + if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: packet allocation failed"); return -1; } + entry->dgram[0] = ETCP_RT_ID_DEVGROUP; entry->dgram[1] = kind; memcpy(entry->dgram + 2, p->id, 16); + if (n) memcpy(entry->dgram + 18, body, n); + entry->len = 18 + n; + char id[33]; dg_hex(p->id, 16, id); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup TX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu", + id, (unsigned long long)p->peer, kind, p->state, n); + int rc = etcp_route_send(ctx->inst, p->group, p->peer, entry, 0, ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT); + if (rc) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: send failed peer=%016llx kind=%u", (unsigned long long)p->peer, kind); + return rc; +} +static void dg_changed(struct chat_devgroup* ctx) { chat_event_post(ctx->inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0); } +static int dg_keys(struct chat_devgroup* ctx, struct dg_invite* p) { + struct devgroup_record r; uint8_t ep[32], xp[32], data[64 + DEVGROUP_WIRE_MAX]; + int rc = devgroup_load_local(ctx->inst, &r, ep, xp); + if (rc != 1 || memcmp(r.ed, p->group_ed, 32)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: pending transfer cancelled after local membership changed"); p->state = 7; rc = -1; + } else { + int n = devgroup_encode(&r, data + 64, DEVGROUP_WIRE_MAX); + if (n<0) rc = -1; + else { memcpy(data, ep, 32); memcpy(data + 32, xp, 32); rc = dg_send(ctx, p, DG_KEYS, data, 64 + n); } + } + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); OPENSSL_cleanse(data, sizeof(data)); return rc; +} +static void dg_result(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t code) { + uint8_t result[2] = {(uint8_t)p->attempts, code}; + dg_send(ctx, p, DG_RESULT, result, sizeof(result)); +} +static void dg_send_pin(struct chat_devgroup* ctx, struct dg_invite* p) { + uint8_t body[5] = {(uint8_t)p->attempts}; memcpy(body + 1, p->pin, 4); + dg_send(ctx, p, DG_PIN, body, sizeof(body)); OPENSSL_cleanse(body, sizeof(body)); +} +static void dg_receive(struct ETCP_CONN* conn, struct ll_entry* entry) { + struct chat_devgroup* ctx = conn ? dg_ctx(conn->instance) : NULL; + if (!ctx || !entry || !entry->dgram) goto out; + if (entry->len == ROUTER_SVC_HDR_SIZE) goto out; /* reset маршрутизатора: retry остаётся у операции */ + if (entry->lendgram[ROUTER_SVC_FLAGS_OFF]&(ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) != (ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto bad; + uint64_t peer, group; memcpy(&peer, entry->dgram + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, entry->dgram + ROUTER_SVC_GROUP_OFF, 8); + uint8_t x[32], ed[32]; if (dg_peer(ctx->inst, group, peer, x, ed)) goto out; + const uint8_t* data = entry->dgram + ROUTER_SVC_HDR_SIZE; uint8_t kind = *data++; + const uint8_t* id = data; data+=16; size_t n = entry->len-ROUTER_SVC_HDR_SIZE-17; + struct dg_invite* p = dg_find(ctx, id); + char id_hex[33]; dg_hex(id, 16, id_hex); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup RX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu", + id_hex, (unsigned long long)peer, kind, p ? p->state : 0, n); + if (p && (p->peer != peer || p->group != group || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32))) goto bad; + if (kind == DG_KEYS) { + int receipt = chat_devgroup_receipt(ctx->inst, id, peer); + if (receipt<0) goto out; + if (receipt) { + struct dg_invite reply = {.group = group, .peer = peer}; memcpy(reply.id, id, 16); memcpy(reply.x, x, 32); memcpy(reply.ed, ed, 32); + dg_result(ctx, &reply, 0); goto out; + } + } + if (kind == DG_OFFER) { + if (n) goto bad; + if (!p) { + p = dg_slot(ctx, peer); if (!p) goto out; + p->state = 1; p->group = group; p->peer = peer; p->expires = get_time_tb() + DG_TTL; + memcpy(p->id, id, 16); memcpy(p->x, x, 32); memcpy(p->ed, ed, 32); + DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation received peer=%016llx", (unsigned long long)peer); + dg_changed(ctx); + } + goto out; + } + if (!p || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: expired/unknown invitation packet kind=%u", kind); goto out; } + if (kind == DG_PIN && p->outgoing) { + if (n != 5 || data[0] < 1 || data[0] > 3) goto bad; + if (p->state == 1 || p->state == 5) { + if (data[0] == p->attempts) { + if (CRYPTO_memcmp(data + 1, p->last_pin, 4)) goto bad; + dg_result(ctx, p, p->last_result); goto out; + } + if (data[0] != p->attempts + 1) goto bad; + } + if (p->state == 3) { dg_keys(ctx, p); goto out; } + if (p->state == 4) { dg_result(ctx, p, 0); goto out; } + if (p->state != 1) { dg_result(ctx, p, 2); goto out; } + p->attempts = data[0]; memcpy(p->last_pin, data + 1, 4); + if (CRYPTO_memcmp(data + 1, p->pin, 4)) { + if (p->attempts >= 3) p->state = 5; + p->last_result = p->state == 5 ? 2 : 1; + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: PIN rejected peer=%016llx attempt=%u", (unsigned long long)peer, p->attempts); + dg_result(ctx, p, p->state == 5 ? 2 : 1); dg_changed(ctx); goto out; + } + struct devgroup_record own; uint8_t ep[32], xp[32]; int has = devgroup_load_local(ctx->inst, &own, ep, xp); + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); + if (has != 1 || memcmp(p->group_ed, own.ed, 32)) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled after membership changed"); + p->state = 5; dg_result(ctx, p, 2); dg_changed(ctx); goto out; + } + p->state = 3; OPENSSL_cleanse(p->pin, sizeof(p->pin)); + dg_keys(ctx, p); dg_changed(ctx); + } else if (kind == DG_KEYS && !p->outgoing && p->state == 2) { + if (n <= 64) goto bad; + struct devgroup_record r; + if (devgroup_decode(&r, data + 64, n-64)) goto bad; + int rc = chat_devgroup_import(ctx->inst, &r, data, data + 32, p->id, p->peer); + p->state = rc ? 5 : 4; OPENSSL_cleanse(p->pin, sizeof(p->pin)); dg_result(ctx, p, rc ? 2 : 0); dg_changed(ctx); + } else if (kind == DG_RESULT) { + if (n != 2 || data[1] > 2) goto bad; + if (p->outgoing && p->state == 3) p->state = data[1] ? 7 : 4; + else if (!p->outgoing && p->state == 2 && data[0] == p->attempts && data[1]) { + p->state = data[1] == 1 ? 1 : 5; OPENSSL_cleanse(p->pin, sizeof(p->pin)); + } + else goto out; + DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation result peer=%016llx state=%u", (unsigned long long)peer, p->state); dg_changed(ctx); + } else goto bad; + goto out; +bad: + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: malformed/unexpected invitation packet bytes=%u", entry ? entry->len : 0); +out: + if (entry) { if (entry->dgram) OPENSSL_cleanse(entry->dgram, entry->len); queue_dgram_free(entry); queue_entry_free(entry); } +} +static void dg_tick(void* arg) { + struct chat_devgroup* ctx = arg; ctx->timer = NULL; uint64_t now = get_time_tb(); + for (unsigned i = 0; islots[i]; if (!p->state || p->state >= 4) continue; + if (now>p->expires) { + p->state = p->state == 3 ? 7 : 6; OPENSSL_cleanse(p->pin, sizeof(p->pin)); + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation timeout peer=%016llx state=%u", (unsigned long long)p->peer, p->state); + dg_changed(ctx); continue; + } + if (now-p->sentoutgoing && p->state == 1) dg_send(ctx, p, DG_OFFER, NULL, 0); + else if (p->outgoing && p->state == 3) dg_keys(ctx, p); + else if (!p->outgoing && p->state == 2) dg_send_pin(ctx, p); + } + if (++ctx->ticks%5 == 0) chat_devgroup_refresh(ctx->inst); + ctx->timer = uasync_set_timeout(ctx->inst->ua, 10000, ctx, dg_tick,"devgroup"); + if (!ctx->timer) DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: timer allocation failed"); +} +int chat_devgroup_init(struct UTUN_INSTANCE* inst) { + if (!inst || !CC(inst) || CC(inst)->devgroup) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invalid init"); return -1; } + struct chat_devgroup* ctx = u_calloc(1, sizeof(*ctx)); + if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: context allocation failed"); return -1; } + ctx->inst = inst; CC(inst)->devgroup = ctx; + if (etcp_router_bind(inst, ETCP_RT_ID_DEVGROUP, dg_receive) || etcp_router_require_crypto(inst, ETCP_RT_ID_DEVGROUP, ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto fail; + ctx->timer = uasync_set_timeout(inst->ua, 10000, ctx, dg_tick,"devgroup"); if (!ctx->timer) goto fail; + DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service started"); return 0; +fail: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: service initialization failed"); chat_devgroup_destroy(inst); return -1; +} +void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst) { + struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return; + for (unsigned i = 0; i < DG_SLOTS; i++) { + struct dg_invite* p = &ctx->slots[i]; if (!p->state || p->state >= 4) continue; + p->state = p->state == 3 ? 7 : 5; + OPENSSL_cleanse(p->pin, sizeof(p->pin)); OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin)); + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled peer=%016llx state=%u", (unsigned long long)p->peer, p->state); + } + dg_changed(ctx); +} +void chat_devgroup_destroy(struct UTUN_INSTANCE* inst) { + struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return; + chat_devgroup_cancel_pending(inst); + if (ctx->timer) uasync_cancel_timeout(inst->ua, ctx->timer); + etcp_router_unbind(inst, ETCP_RT_ID_DEVGROUP); CC(inst)->devgroup = NULL; + OPENSSL_cleanse(ctx, sizeof(*ctx)); u_free(ctx); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service stopped"); +} +int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]) { + struct chat_devgroup* ctx = dg_ctx(inst); uint8_t x[32], ed[32]; + if (!ctx || !out_id || !out_pin || dg_peer(inst, group, peer, x, ed)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invite rejected"); return -1; } + struct dg_invite* p = dg_slot(ctx, peer); if (!p) return -1; + struct devgroup_record own; uint8_t ep[32], xp[32]; + int has = devgroup_load_local(inst, &own, ep, xp); + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); + if (!has) { + if (chat_devgroup_rename(inst, inst->name[0] ? inst->name : "My devices")) return -1; + has = devgroup_load_local(inst, &own, ep, xp); + OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); + } + if (has != 1) return -1; + memcpy(p->group_ed, own.ed, 32); + uint32_t random; + do { if (RAND_bytes((uint8_t*)&random, sizeof(random)) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: PIN randomness failed"); return -1; } } + while (random >= UINT32_MAX-UINT32_MAX%10000); + if (RAND_bytes(p->id, 16) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invitation randomness failed"); return -1; } + snprintf(p->pin, sizeof(p->pin),"%04u", random%10000); p->group = group; p->peer = peer; p->outgoing = 1; p->state = 1; p->expires = get_time_tb() + DG_TTL; + memcpy(p->x, x, 32); memcpy(p->ed, ed, 32); dg_hex(p->id, 16, out_id); memcpy(out_pin, p->pin, 5); + dg_send(ctx, p, DG_OFFER, NULL, 0); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation created peer=%016llx", (unsigned long long)peer); + dg_changed(ctx); return 0; +} +int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char* pin) { + struct chat_devgroup* ctx = dg_ctx(inst); uint8_t binary[16]; + if (!ctx || dg_id(id, binary) || !pin || strlen(pin) != 4 || strspn(pin,"0123456789") != 4) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invalid acceptance arguments"); return -1; + } + struct dg_invite* p = dg_find(ctx, binary); + if (!p || p->outgoing || p->state != 1 || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: acceptance unavailable"); return -1; } + if (p->attempts >= 3) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: PIN attempts exhausted"); return -1; } + memcpy(p->pin, pin, 5); p->attempts++; p->state = 2; + dg_send_pin(ctx, p); dg_changed(ctx); return 0; +} +char* chat_devgroup_status(struct UTUN_INSTANCE* inst) { + struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: status unavailable"); return NULL; } + struct devgroup_record r; struct devgroup_profile profile; uint8_t ep[32], xp[32]; char id[65] = ""; + int found = devgroup_load_local(inst, &r, ep, xp); OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); if (found<0) return NULL; + struct strbuf json = {0}; + if (found) { if (devgroup_profile_read(&r, &profile)) return NULL; dg_hex(r.ed, 32, id); } + if (strbuf_addf(&json,"{\"group\":\"%s\",\"name\":", id)<0 || strbuf_add_json_string(&json, found ? profile.name : "") || strbuf_addf(&json,",\"invitations\":[")<0) goto fail; + unsigned count = 0; + for (unsigned i = 0; islots[i]; if (!p->state) continue; dg_hex(p->id, 16, id); + if (strbuf_addf(&json,"%s{\"id\":\"%s\",\"peer\":\"%016llx\",\"channel\":\"%llu\",\"outgoing\":%u,\"state\":%u}", + count++ ? "," : "", id, (unsigned long long)p->peer, (unsigned long long)p->group, p->outgoing, p->state)<0) goto fail; + } + if (strbuf_addf(&json,"]}")<0) goto fail; + return strbuf_detach(&json); +fail: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: status allocation failed"); strbuf_free(&json); return NULL; +} diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index 5804981fd..8fd8f6f77 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -64,12 +64,12 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [60]="ATTACHMENT_KEYS_CHANGED", [61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE", [65]="RADIO_CALL", [66]="CALL_PHONE_BUSY", [67]="MSG_UPDATED", - [69]="NETWORK_STATS", + [69]="NETWORK_STATS", [70]="DEVGROUP_CHANGED", [71]="DEVGROUP_RESULT", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : NULL; if (!n) n = "?"; DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len); - if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE && type != CHAT_EVT_INVITE_LINK_READY) + if (data && len > 0 && type != CHAT_EVT_DEVGROUP_RESULT && type != CHAT_EVT_LOCAL_INVITE && type != CHAT_EVT_INVITE_LINK_READY) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len); } diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 9ad9c0fb3..4a3f25be7 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -93,6 +93,8 @@ extern "C" { #define CHAT_EVT_CALL_PHONE_BUSY 66 /* [id:8LE][reserved:1]; вызывающий выбирает продолжить либо завершить */ #define CHAT_EVT_MSG_UPDATED 67 /* [channel_len:1][channel][message_id:32] — правка после commit, без unread/звука */ #define CHAT_EVT_GROUP_AVATAR_SNAPSHOT 68 /* Только Android JNI: id/group/version:8LE, hash:32, can_edit/failed:1, size:2LE, image:size. */ +#define CHAT_EVT_DEVGROUP_RESULT 71 /* request_id LE8 + success1 + JSON; может содержать PIN, не логировать */ +#define CHAT_EVT_DEVGROUP_CHANGED 70 /* Обновление nodes/devgroup: перезагрузить снимки участников. */ #define CHAT_EVT_NETWORK_STATS 69 /* Native POD снимок ETCP-подключений и линков, chat_network_stats.h. */ // ── Подключение получателя событий ── diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index 27a4d5249..bdfe38f8f 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -1,3 +1,5 @@ +#include "chat_devgroup.h" +#include /* * chat_headless_control.c — TCP control socket for headless chat CLI * @@ -990,6 +992,31 @@ static void hc_handle_feedback(struct headless_client* cli, int id, const char* u_free(result); } +static void hc_handle_devgroup(struct headless_client* cli, int id, const char* cmd) { + struct chat_devgroup_request req = {.inst = cli->hc->inst}; + if (!strcmp(cmd, "devgroup_status")) req.action = CHAT_DEVGROUP_STATUS; + else if (!strcmp(cmd, "devgroup_leave")) req.action = CHAT_DEVGROUP_LEAVE; + else if (!strcmp(cmd, "devgroup_rename")) { + req.action = CHAT_DEVGROUP_RENAME; + if (json_get_str(cli, "name", req.text, sizeof(req.text))) goto invalid; + } else if (!strcmp(cmd, "devgroup_accept")) { + req.action = CHAT_DEVGROUP_ACCEPT; + if (json_get_str(cli, "invite", req.text, sizeof(req.text)) || json_get_str(cli, "pin", req.pin, sizeof(req.pin))) goto invalid; + } else if (!strcmp(cmd, "devgroup_invite")) { + char channel[32], peer[32]; req.action = CHAT_DEVGROUP_INVITE; + if (json_get_str(cli, "ch", channel, sizeof(channel)) || json_get_str(cli, "peer", peer, sizeof(peer)) || + !chat_channel_id(channel) || strlen(peer) != 16 || strspn(peer, "0123456789abcdef") != 16) goto invalid; + req.group = chat_channel_id(channel); req.peer = strtoull(peer, NULL, 16); + } else goto invalid; + char* result = NULL; int rc = chat_devgroup_execute(&req, &result); + send_response(cli, id, result, rc ? "devgroup operation failed" : NULL); + if (result) { OPENSSL_cleanse(result, strlen(result)); u_free(result); } + OPENSSL_cleanse(&req, sizeof(req)); return; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: invalid headless command"); + OPENSSL_cleanse(&req, sizeof(req)); send_response(cli, id, NULL, "invalid devgroup command"); +} + static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "call_start", "call_accept", "call_decline", "call_hangup", "radio_on", "radio_off", "radio_subscribers", "server_link", "server_add", "quit", NULL }; static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_radio_on, hc_handle_radio_off, hc_handle_radio_subscribers, hc_handle_server_link, hc_handle_server_add, hc_handle_quit }; @@ -1005,6 +1032,7 @@ static void hc_handle_command(struct headless_client* cli, const char* json) { || !strcmp(cmd, "feedback") || !strcmp(cmd, "reaction_catalog")) { hc_handle_feedback(cli, id, json); goto done; } + if (!strncmp(cmd, "devgroup_", 9)) { hc_handle_devgroup(cli, id, cmd); goto done; } for (int i = 0; cmd_names[i]; i++) { if (strcmp(cmd, cmd_names[i]) == 0) { cmd_handlers[i](cli, id, json); goto done; } } diff --git a/src/chat/chat_member.c b/src/chat/chat_member.c index e7a9d308d..a1c27d036 100644 --- a/src/chat/chat_member.c +++ b/src/chat/chat_member.c @@ -107,12 +107,16 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* dst[158] = src->verified; dst[159] = src->node_type; dst[160] = src->conn_presence; dst[161] = src->conn_up; dst[162] = (uint8_t)src->intermediaries; dst[163] = (uint8_t)(src->intermediaries >> 8); + memcpy(dst + 164, src->devgroup, 32); + strncpy((char*)dst + 196, src->devgroup_name, 63); dst[259] = 0; DEBUG_TRACE(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: serialized node=%016llx name_bytes=%zu zero_padded=1", (unsigned long long)src->node_id, strlen((const char*)dst + 15)); } int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst) { if (!src || !dst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member deserialization: missing argument"); return -1; } + memcpy(dst->devgroup, src + 164, 32); + memcpy(dst->devgroup_name, src + 196, 64); dst->devgroup_name[63] = 0; dst->node_id = mf_get64(src); dst->online = src[8]; dst->is_self = src[9]; @@ -234,7 +238,11 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl } members = tmp; } - member_from_row(inst, ch_id, st, &members[cnt++]); + member_from_row(inst, ch_id, st, &members[cnt]); + if (devgroup_member(cc->db, members[cnt].node_id, members[cnt].devgroup, members[cnt].devgroup_name) < 0) { + rc = SQLITE_CORRUPT; break; + } + cnt++; } done: { diff --git a/src/chat/chat_member.h b/src/chat/chat_member.h index b8ba35cc3..3a756559f 100644 --- a/src/chat/chat_member.h +++ b/src/chat/chat_member.h @@ -8,7 +8,7 @@ * Все вызовы — в uasync-потоке. ch_id — канонический десятичный ID1..INT64_MAX. * В список входят self,placeholders и deleted;deleted показывается offline,self обычно online. * - * Формат на проводе (ручная сериализация, фиксированный размер 164 байта): + * Формат на проводе (ручная сериализация, фиксированный размер 260 байта): * [0-7] node_id uint64_t LE * [8] online uint8_t * [9] is_self uint8_t @@ -25,6 +25,8 @@ * [159] node_type uint8_t, вычисленный тип узла из peers * [160] conn_presence uint8_t, известные виды подключения NCONN_* * [161] conn_up uint8_t, работающие виды подключения NCONN_* + * [164-195] devgroup Ed25519 public key, нули — без группы + * [196-259] devgroup_name UTF-8 char[64], NUL-padded * [162-163] intermediaries uint16_t LE, число посредников выбранного пути */ @@ -49,7 +51,7 @@ extern "C" { #define CHAT_MEMBER_FLAG_PHONE_KNOWN 0x40 /* опубликовано состояние телефонной занятости */ #define CHAT_MEMBER_FLAG_PHONE_BUSY 0x80 /* занят звонком; цвет трубки в интерфейсе */ -#define CHAT_MEMBER_DISPLAY_SIZE 164 +#define CHAT_MEMBER_DISPLAY_SIZE 260 /* Снять полный список:0 — успех,-1 — ошибка/более65535 записей. Обязательные out/count сбрасываются в NULL/0. * out — u_malloc-массив count*CHAT_MEMBER_DISPLAY_SIZE байт,освобождает caller через u_free;пустой список — NULL/0. @@ -66,6 +68,8 @@ int chat_core_member_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint6 /* Внутренний снимок:поля далее кодируются вручную,struct-layout в wire не передаётся. */ struct chat_member_display { + uint8_t devgroup[32]; /* Проверенный Ed25519 ID группы устройств; нули — нет группы. */ + char devgroup_name[64]; /* Имя наиболее свежего профиля из nodes. */ uint64_t node_id; /* ID участника. */ uint8_t online; /* Живой путь/self,сброшен для deleted. */ uint8_t is_self; /* Совпадение с node_id instance. */ @@ -84,14 +88,14 @@ struct chat_member_display { uint16_t intermediaries; /* число посредников выбранного живого пути */ }; -/* Копирование фиксированного wire164:src/dst обязательны,caller обеспечивает размеры. +/* Копирование фиксированного wire260:src/dst обязательны,caller обеспечивает размеры. * Serialize завершает/дополняет NUL поля name/avatar_file;NULL только логируется. */ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* dst); -/* Deserialize читает ровно164 доступных байта;0/-1 при NULL,длину/семантику полей проверяет caller. */ +/* Deserialize читает ровно260 доступных байта;0/-1 при NULL,длину/семантику полей проверяет caller. */ int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst); /* Трамплин для gui_bridge, только uasync: принимает собственный u_malloc-блок arg и освобождает его. - * ch_id — канонический ID+NUL. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535), записи wire164 LE. + * ch_id — канонический ID+NUL. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535), записи wire260 LE. * Непредставимый count/ошибка чата/памяти логируются, события нет; ложный/усечённый снимок не отправляется. */ struct chat_member_list_req { struct UTUN_INSTANCE* inst; char ch_id[64]; }; void chat_core_request_member_list_trampoline(void* arg); diff --git a/src/routing_layer/devgroup.c b/src/routing_layer/devgroup.c new file mode 100644 index 000000000..71785ae4d --- /dev/null +++ b/src/routing_layer/devgroup.c @@ -0,0 +1,293 @@ +#include "devgroup.h" +#include "topo_node.h" +#include "../utun_instance.h" +#include "../transport_layer/secure_channel.h" +#include "../../lib/json_flat.h" +#include "../../lib/strbuf.h" +#include "../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include +#include + +static int dg_error(const char* stage) { + DEBUG_WARN(DEBUG_CATEGORY_BGP, "devgroup: rejected stage=%s", stage); return -1; +} +static int dg_sql(sqlite3* db, const char* sql) { + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: SQL failed rc=%d: %s", rc, sqlite3_errmsg(db)); + return rc == SQLITE_OK ? 0 : -1; +} +static int dg_finish(sqlite3* db, sqlite3_stmt* st, int rc, int expected) { + int final = sqlite3_finalize(st); + if (rc == expected && final == SQLITE_OK) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: SQL rc=%d expected=%d finalize=%d: %s", rc, expected, final, sqlite3_errmsg(db)); + return -1; +} +static int dg_utf8(const char* text) { + const unsigned char* p = (const unsigned char*)text; + while (*p) { + uint32_t c = *p++; unsigned n; uint32_t min; + if (c < 128) { if (c < 32) return 0; continue; } + if (c >= 0xc2 && c <= 0xdf) { n = 1; min = 0x80; c &= 31; } + else if (c >= 0xe0 && c <= 0xef) { n = 2; min = 0x800; c &= 15; } + else if (c >= 0xf0 && c <= 0xf4) { n = 3; min = 0x10000; c &= 7; } + else return 0; + while (n--) { if ((*p & 0xc0) != 0x80) return 0; c = (c << 6) | (*p++ & 63); } + if (c < min || c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return 0; + } + return 1; +} +/* Строгий каркас плоского JSON; escapes и Unicode проверяет json_flat_parse. */ +static void dg_ws(const char** p) { while (**p && strchr(" \t\r\n", **p)) ++*p; } +static int dg_string(const char** p) { + if (*(*p)++ != '"') return -1; + while (**p && **p != '"') { + if (**p == '\\') { ++*p; if (!**p) return -1; } + ++*p; + } + if (!**p) return -1; + ++*p; return 0; +} +static int dg_syntax(const char* p) { + dg_ws(&p); if (*p++ != '{') return -1; + dg_ws(&p); + if (*p != '}') while (1) { + if (dg_string(&p)) return -1; + dg_ws(&p); if (*p++ != ':') return -1; + dg_ws(&p); if (dg_string(&p)) return -1; + dg_ws(&p); if (*p == '}') break; + if (*p++ != ',') return -1; + dg_ws(&p); + } + p++; dg_ws(&p); return *p ? -1 : 0; +} +static int dg_number(const char* text, uint64_t* out) { + if (*text < '1' || *text > '9') return -1; + uint64_t value = 0; + for (const char* p = text; *p; p++) { + if (*p < '0' || *p > '9' || value > ((uint64_t)INT64_MAX - (*p - '0')) / 10) return -1; + value = value * 10 + (*p - '0'); + } + *out = value; return 0; +} +struct dg_parser { struct devgroup_profile p; uint8_t x[32]; unsigned fields, count; char keys[32][64]; }; +static int dg_field(const char* key, const char* value, void* arg) { + struct dg_parser* p = arg; + if (!*key || strlen(key) > 63 || p->count == 32) return -1; + for (unsigned i = 0; i < p->count; i++) if (!strcmp(p->keys[i], key)) return -1; + strcpy(p->keys[p->count++], key); + if (!strcmp(key, "name")) { + if (!*value || strlen(value) > 63 || !dg_utf8(value)) return -1; + strcpy(p->p.name, value); p->fields |= 1; + } else if (!strcmp(key, "ver")) { if (dg_number(value, &p->p.version)) return -1; p->fields |= 2; + } else if (!strcmp(key, "timestamp")) { if (dg_number(value, &p->p.timestamp)) return -1; p->fields |= 4; + } else if (!strcmp(key, "x25519")) { + if (strlen(value) != 64 || strspn(value, "0123456789abcdef") != 64 || sc_hex_to_binary(value, p->x, 32)) return -1; + p->fields |= 8; + } + return 0; +} +static size_t dg_profile_message(const char* json, uint8_t* out) { + const char domain[] = "utun-devgroup-profile-v1"; + memcpy(out, domain, sizeof(domain)); size_t n = strlen(json); + memcpy(out + sizeof(domain), json, n); return sizeof(domain) + n; +} +static size_t dg_member_message(const struct devgroup_record* r, uint64_t node, const uint8_t x[32], const uint8_t ed[32], uint8_t* out) { + const char domain[] = "utun-devgroup-member-v1"; size_t n = sizeof(domain); + memcpy(out, domain, n); memcpy(out + n, r->ed, 32); n += 32; memcpy(out + n, r->x, 32); n += 32; + for (unsigned i = 0; i < 8; i++) out[n++] = (uint8_t)(node >> (8 * i)); + memcpy(out + n, x, 32); n += 32; memcpy(out + n, ed, 32); return n + 32; +} +int devgroup_profile_read(const struct devgroup_record* r, struct devgroup_profile* out) { + if (!r || !out || !r->json[0] || strnlen(r->json, sizeof(r->json)) > DEVGROUP_PROFILE_MAX) return dg_error("profile length"); + struct dg_parser p = {0}; uint8_t body[DEVGROUP_PROFILE_MAX + 32]; + if (dg_syntax(r->json) || json_flat_parse(r->json, dg_field, &p) || p.fields != 15 || memcmp(p.x, r->x, 32)) + return dg_error("profile JSON"); + size_t n = dg_profile_message(r->json, body); + if (sc_ed25519_verify(r->ed, body, n, r->profile_sig) != SC_OK) return dg_error("profile signature"); + SHA256((const uint8_t*)r->json, strlen(r->json), p.p.hash); *out = p.p; return 0; +} +int devgroup_verify(const struct devgroup_record* r, uint64_t node, const uint8_t x[32], const uint8_t ed[32]) { + if (!r || !x || !ed) return dg_error("membership arguments"); + if (!r->json[0]) { + const struct devgroup_record zero = {0}; + return memcmp(r, &zero, sizeof(zero)) ? dg_error("partial membership") : 0; + } + struct devgroup_profile p; uint8_t body[192]; + if (devgroup_profile_read(r, &p)) return -1; + size_t n = dg_member_message(r, node, x, ed, body); + if (sc_ed25519_verify(r->ed, body, n, r->member_sig) != SC_OK) return dg_error("membership signature"); + return 0; +} +int devgroup_encode(const struct devgroup_record* r, uint8_t* out, size_t capacity) { + if (!r || !out) return dg_error("encode arguments"); + size_t n = strnlen(r->json, sizeof(r->json)); if (!n) return 0; + if (n > DEVGROUP_PROFILE_MAX || capacity < 192 + n) return dg_error("encode capacity"); + memcpy(out, r->ed, 32); memcpy(out + 32, r->x, 32); memcpy(out + 64, r->member_sig, 64); + memcpy(out + 128, r->profile_sig, 64); memcpy(out + 192, r->json, n); return (int)(192 + n); +} +int devgroup_decode(struct devgroup_record* out, const uint8_t* data, size_t size) { + if (!out || (size && (!data || size <= 192 || size > DEVGROUP_WIRE_MAX || memchr(data + 192, 0, size - 192)))) + return dg_error("decode length"); + struct devgroup_record r = {0}; + if (size) { + memcpy(r.ed, data, 32); memcpy(r.x, data + 32, 32); memcpy(r.member_sig, data + 64, 64); + memcpy(r.profile_sig, data + 128, 64); memcpy(r.json, data + 192, size - 192); + } + *out = r; return 0; +} +int devgroup_profile_compare(const struct devgroup_profile* a, const struct devgroup_profile* b) { + if (a->version != b->version) return a->version > b->version ? 1 : -1; + if (a->timestamp != b->timestamp) return a->timestamp > b->timestamp ? 1 : -1; + return memcmp(a->hash, b->hash, 32); +} +int devgroup_bind(struct devgroup_record* r, const uint8_t priv[32], uint64_t node, const uint8_t x[32], const uint8_t ed[32]) { + if (!r || !priv || !node || !x || !ed) return dg_error("bind arguments"); + uint8_t body[192]; size_t n = dg_member_message(r, node, x, ed, body); + if (sc_ed25519_sign(priv, body, n, r->member_sig) != SC_OK) return dg_error("bind signature"); + return devgroup_verify(r, node, x, ed); +} +/* При локальном переименовании сохраняем неизвестные строковые поля расширений. */ +static int dg_copy_extension(const char* key, const char* value, void* arg) { + if (!strcmp(key, "name") || !strcmp(key, "ver") || !strcmp(key, "timestamp") || !strcmp(key, "x25519")) return 0; + struct strbuf* json = arg; + return strbuf_addf(json, ",") < 0 || strbuf_add_json_string(json, key) || strbuf_addf(json, ":") < 0 || + strbuf_add_json_string(json, value) ? -1 : 0; +} +int devgroup_sign(struct devgroup_record* r, const uint8_t priv[32], const char* name, + uint64_t version, uint64_t timestamp, uint64_t node, const uint8_t x[32], const uint8_t ed[32]) { + if (!r || !priv || !name || !*name || strlen(name) > 63 || !dg_utf8(name) || !version || version > INT64_MAX || + !timestamp || timestamp > INT64_MAX || !node || !x || !ed) return dg_error("sign arguments"); + struct devgroup_record next = *r; struct strbuf json = {0}; char hex[65]; + for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", r->x[i]); + int rc = strbuf_addf(&json, "{\"ver\":\"%llu\",\"timestamp\":\"%llu\",\"x25519\":\"%s\",\"name\":", + (unsigned long long)version, (unsigned long long)timestamp, hex); + if (rc < 0 || strbuf_add_json_string(&json, name) || + (r->json[0] && json_flat_parse(r->json, dg_copy_extension, &json)) || strbuf_addf(&json, "}") < 0 || json.len > DEVGROUP_PROFILE_MAX) { + strbuf_free(&json); return dg_error("JSON allocation/size"); + } + memset(next.json, 0, sizeof(next.json)); memcpy(next.json, json.buf, json.len); strbuf_free(&json); + uint8_t body[DEVGROUP_PROFILE_MAX + 32]; size_t n = dg_profile_message(next.json, body); + if (sc_ed25519_sign(priv, body, n, next.profile_sig) != SC_OK) return dg_error("profile signing"); + n = dg_member_message(&next, node, x, ed, body); + if (sc_ed25519_sign(priv, body, n, next.member_sig) != SC_OK || devgroup_verify(&next, node, x, ed)) + return dg_error("membership signing"); + *r = next; return 0; +} +int devgroup_public_keys(const uint8_t ep[32], const uint8_t xp[32], uint8_t ed[32], uint8_t x[32]) { + EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, ep, 32); size_t n = 32; + int ok = key && EVP_PKEY_get_raw_public_key(key, ed, &n) == 1 && n == 32; + EVP_PKEY_free(key); + if (!ok || sc_compute_public_key_from_private(xp, x) != SC_OK) return dg_error("key derivation"); + return 0; +} +int devgroup_schema(sqlite3* db) { + if (!db) return dg_error("schema database"); + const char* names[] = {"devgroup_ed", "devgroup_x", "devgroup_member_sig", "devgroup_json", "devgroup_profile_sig"}; + for (unsigned i = 0; i < 5; i++) { + sqlite3_stmt* st = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM pragma_table_info('nodes') WHERE name=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, names[i], -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int exists = rc == SQLITE_ROW && sqlite3_column_int(st, 0); + if (dg_finish(db, st, rc, SQLITE_ROW)) return -1; + if (!exists) { + char sql[128]; snprintf(sql, sizeof(sql), "ALTER TABLE nodes ADD COLUMN %s %s", names[i], i == 3 ? "TEXT" : "BLOB"); + if (dg_sql(db, sql)) return -1; + } + } + return dg_sql(db, "CREATE INDEX IF NOT EXISTS nodes_devgroup ON nodes(devgroup_ed);" + "CREATE TABLE IF NOT EXISTS devgroup_local(node_id INTEGER PRIMARY KEY,ed_priv BLOB NOT NULL,x_priv BLOB NOT NULL,record BLOB NOT NULL);" + "CREATE TABLE IF NOT EXISTS devgroup_receipts(node_id INTEGER NOT NULL,id BLOB NOT NULL,peer INTEGER NOT NULL,PRIMARY KEY(node_id,id));"); +} +int devgroup_project(sqlite3* db, const struct TOPO_NODE* node) { + const struct devgroup_record* r = &node->devgroup; sqlite3_stmt* st = NULL; + const char* sql = "INSERT INTO nodes(node_id,x25519_pubkey,ed25519_pubkey,devgroup_ed,devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig)" + " VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(node_id) DO UPDATE SET devgroup_ed=excluded.devgroup_ed,devgroup_x=excluded.devgroup_x," + "devgroup_member_sig=excluded.devgroup_member_sig,devgroup_json=excluded.devgroup_json,devgroup_profile_sig=excluded.devgroup_profile_sig"; + int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node->node_id); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, node->public_key, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, node->ed25519_public_key, 32, SQLITE_STATIC); + const void* blobs[] = {r->ed, r->x, r->member_sig, r->profile_sig}; const int sizes[] = {32,32,64,64}; + for (unsigned i = 0; i < 4 && rc == SQLITE_OK; i++) { + int col = i == 3 ? 8 : 4 + (int)i; + rc = r->json[0] ? sqlite3_bind_blob(st, col, blobs[i], sizes[i], SQLITE_STATIC) : sqlite3_bind_null(st, col); + } + if (rc == SQLITE_OK) rc = r->json[0] ? sqlite3_bind_text(st, 7, r->json, -1, SQLITE_STATIC) : sqlite3_bind_null(st, 7); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_DONE && sqlite3_changes(db) != 1) rc = SQLITE_CONSTRAINT; + return dg_finish(db, st, rc, SQLITE_DONE); +} +int devgroup_load_local(struct UTUN_INSTANCE* inst, struct devgroup_record* out, uint8_t ep[32], uint8_t xp[32]) { + sqlite3* db = inst ? inst->topo_sqlite_db : NULL; + if (!db || !out || !ep || !xp) return dg_error("load arguments"); + sqlite3_stmt* st = NULL; struct devgroup_record r = {0}; uint8_t e[32] = {0}, x[32] = {0}; int found = 0; + int rc = sqlite3_prepare_v2(db, "SELECT ed_priv,x_priv,record FROM devgroup_local WHERE node_id=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + if (rc == SQLITE_ROW) { + if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 32 || + devgroup_decode(&r, sqlite3_column_blob(st, 2), sqlite3_column_bytes(st, 2)) || !r.json[0]) rc = SQLITE_CORRUPT; + else { memcpy(e, sqlite3_column_blob(st, 0), 32); memcpy(x, sqlite3_column_blob(st, 1), 32); found = 1; rc = SQLITE_DONE; } + } + int error = dg_finish(db, st, rc, SQLITE_DONE); + if (!error && found) { + uint8_t ed[32], pub[32]; + if (devgroup_public_keys(e, x, ed, pub) || memcmp(ed, r.ed, 32) || memcmp(pub, r.x, 32) || + devgroup_verify(&r, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey)) error = dg_error("stored identity"); + } + if (!error) { *out = r; memcpy(ep, e, 32); memcpy(xp, x, 32); } + OPENSSL_cleanse(e, 32); OPENSSL_cleanse(x, 32); return error ? -1 : found; +} +int devgroup_latest(sqlite3* db, const uint8_t id[32], struct devgroup_record* out, struct devgroup_profile* profile) { + if (!db || !id || !out || !profile) return dg_error("latest arguments"); + sqlite3_stmt* st = NULL; struct devgroup_record best = {0}; struct devgroup_profile bp = {0}; int found = 0; + int rc = sqlite3_prepare_v2(db, "SELECT devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig," + "node_id,x25519_pubkey,ed25519_pubkey FROM nodes WHERE devgroup_ed=?", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, id, 32, SQLITE_STATIC); + if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + struct devgroup_record r = {0}; struct devgroup_profile p; + int n = sqlite3_column_bytes(st, 2); const char* json = (const char*)sqlite3_column_text(st, 2); + if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 64 || sqlite3_column_bytes(st, 3) != 64 || + sqlite3_column_bytes(st, 5) != 32 || sqlite3_column_bytes(st, 6) != 32 || !json || n <= 0 || n > DEVGROUP_PROFILE_MAX || + memchr(json, 0, n)) { rc = SQLITE_CORRUPT; break; } + memcpy(r.ed, id, 32); memcpy(r.x, sqlite3_column_blob(st, 0), 32); memcpy(r.member_sig, sqlite3_column_blob(st, 1), 64); + memcpy(r.json, json, n); memcpy(r.profile_sig, sqlite3_column_blob(st, 3), 64); + if (devgroup_verify(&r, (uint64_t)sqlite3_column_int64(st, 4), sqlite3_column_blob(st, 5), sqlite3_column_blob(st, 6)) || + devgroup_profile_read(&r, &p)) { rc = SQLITE_CORRUPT; break; } + if (!found || devgroup_profile_compare(&p, &bp) > 0) { best = r; bp = p; found = 1; } + } + if (dg_finish(db, st, rc, SQLITE_DONE)) return -1; + *out = best; *profile = bp; return found; +} + +int devgroup_member(sqlite3* db, uint64_t node, uint8_t id[32], char name[64]) { + sqlite3_stmt* st = NULL; struct devgroup_record r = {0}, best; struct devgroup_profile profile; + int rc = sqlite3_prepare_v2(db, "SELECT devgroup_ed,devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig," + "x25519_pubkey,ed25519_pubkey FROM nodes WHERE node_id=? AND devgroup_ed IS NOT NULL", -1, &st, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node); + if (rc == SQLITE_OK) rc = sqlite3_step(st); + int found = rc == SQLITE_ROW; + if (found) { + int n = sqlite3_column_bytes(st, 3); const char* json = (const char*)sqlite3_column_text(st, 3); + if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 32 || sqlite3_column_bytes(st, 2) != 64 || + sqlite3_column_bytes(st, 4) != 64 || sqlite3_column_bytes(st, 5) != 32 || sqlite3_column_bytes(st, 6) != 32 || + !json || n <= 0 || n > DEVGROUP_PROFILE_MAX || memchr(json, 0, n)) rc = SQLITE_CORRUPT; + else { + memcpy(r.ed, sqlite3_column_blob(st, 0), 32); memcpy(r.x, sqlite3_column_blob(st, 1), 32); + memcpy(r.member_sig, sqlite3_column_blob(st, 2), 64); memcpy(r.json, json, n); + memcpy(r.profile_sig, sqlite3_column_blob(st, 4), 64); + rc = devgroup_verify(&r, node, sqlite3_column_blob(st, 5), sqlite3_column_blob(st, 6)) ? SQLITE_CORRUPT : SQLITE_DONE; + } + } + if (dg_finish(db, st, rc, SQLITE_DONE)) return -1; + memset(id, 0, 32); memset(name, 0, 64); + if (!found) return 0; + if (devgroup_latest(db, r.ed, &best, &profile) != 1) return dg_error("member profile unavailable"); + memcpy(id, r.ed, 32); memcpy(name, profile.name, 64); return 1; +} diff --git a/src/routing_layer/devgroup.h b/src/routing_layer/devgroup.h new file mode 100644 index 000000000..982054528 --- /dev/null +++ b/src/routing_layer/devgroup.h @@ -0,0 +1,57 @@ +/* devgroup — общая подписанная принадлежность устройства и профиль группы. + * Публичная запись хранится по значению в TOPO_NODE; нулевая запись означает отсутствие группы. + * JSON пересылается без пересериализации, ключи группы независимы от ключей устройства. + * Все операции БД/instance вызываются в uasync-потоке; codec не обращается к instance. */ +#ifndef UTUN_DEVGROUP_H +#define UTUN_DEVGROUP_H +#include +#include +struct sqlite3; +struct UTUN_INSTANCE; +struct TOPO_NODE; +#ifdef __cplusplus +extern "C" { +#endif +#define DEVGROUP_PROFILE_MAX 768 +#define DEVGROUP_WIRE_MAX (192 + DEVGROUP_PROFILE_MAX) +struct devgroup_record { + uint8_t ed[32], x[32]; /* Общие публичные ключи подписи и шифрования. */ + uint8_t member_sig[64]; /* Подпись идентичности конкретного устройства ключом группы. */ + uint8_t profile_sig[64]; /* Подпись исходных байтов JSON ключом группы. */ + char json[DEVGROUP_PROFILE_MAX + 1]; /* Полный строковый JSON; пустой — группы нет. */ +}; +struct devgroup_profile { + uint64_t version, timestamp; /* Порядок выбора профиля, независимо от timestamp ноды. */ + char name[64]; /* UTF-8 ник, максимум 63 байта. */ + uint8_t hash[32]; /* Последний критерий при равенстве version/timestamp. */ +}; +/* 0/-1: проверить весь JSON и подпись; out меняется только при успехе. Запись обязательна и непустая. */ +int devgroup_profile_read(const struct devgroup_record* record, struct devgroup_profile* out); +/* 0/-1: проверить пустую запись либо обе подписи и привязку к ключам устройства. */ +int devgroup_verify(const struct devgroup_record* record, uint64_t node, const uint8_t x[32], const uint8_t ed[32]); +/* Codec: encode возвращает число байтов (0 для пустой записи), decode0/-1. Буферы caller, без struct-padding на проводе. */ +int devgroup_encode(const struct devgroup_record* record, uint8_t* out, size_t capacity); +int devgroup_decode(struct devgroup_record* out, const uint8_t* data, size_t size); +/* Сравнение проверенных профилей: отрицательное/0/положительное, a относительно b. */ +int devgroup_profile_compare(const struct devgroup_profile* a, const struct devgroup_profile* b); +/* Подписать только удостоверение устройства, сохраняя исходные JSON/подпись профиля; 0/-1. */ +int devgroup_bind(struct devgroup_record* record, const uint8_t priv[32], uint64_t node, const uint8_t x[32], const uint8_t ed[32]); +/* Создать/переименовать профиль и удостоверение. priv — Ed25519 seed; версии положительные <=INT64_MAX; 0/-1. */ +int devgroup_sign(struct devgroup_record* record, const uint8_t priv[32], const char* name, + uint64_t version, uint64_t timestamp, uint64_t node, const uint8_t x[32], const uint8_t ed[32]); +/* Схема постоянного локального состояния и полей nodes; повторный вызов допустим. 0/-1. */ +int devgroup_schema(struct sqlite3* db); +/* Внутри транзакции snapshot: записать публичную проекцию узла, 0/-1. */ +int devgroup_project(struct sqlite3* db, const struct TOPO_NODE* node); +/* Загрузить собственную запись и проверить обе пары секретов. 0=нет группы,1=загружена,-1=ошибка. */ +int devgroup_load_local(struct UTUN_INSTANCE* inst, struct devgroup_record* out, uint8_t ed_priv[32], uint8_t x_priv[32]); +/* Поиск максимального проверенного объявления среди nodes, 0/1/-1. */ +int devgroup_latest(struct sqlite3* db, const uint8_t id[32], struct devgroup_record* out, struct devgroup_profile* profile); +/* Проверенная принадлежность узла из nodes и самое свежее имя;0=нет,1=найдено,-1=ошибка. Буферы32/64 байта. */ +int devgroup_member(struct sqlite3* db, uint64_t node, uint8_t id[32], char name[64]); +/* Независимые публичные ключи из seed, 0/-1. */ +int devgroup_public_keys(const uint8_t ed_priv[32], const uint8_t x_priv[32], uint8_t ed[32], uint8_t x[32]); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/routing_layer/topo_group.c b/src/routing_layer/topo_group.c index eeec0ee98..a6652ff8c 100644 --- a/src/routing_layer/topo_group.c +++ b/src/routing_layer/topo_group.c @@ -1455,7 +1455,7 @@ int topo_group_process_nodeinfo(struct TOPO_GROUP* group, struct ETCP_CONN* from if (sdb) { if (topo_node_sqlite_snapshot_put(sdb, new_ni) < 0) DEBUG_ERROR(DEBUG_CATEGORY_BGP, "cannot persist received NODEINFO node=%016llx", (unsigned long long)node_id); - /* узлы в nodes пишет только merkle-путь; BGP лишь дополняет IP для мемберов */ + /* Snapshot сохраняет общую принадлежность devgroup; адреса дополняем для известных мемберов. */ if (topo_node_sqlite_member_exists(sdb, node_id)) topo_node_sqlite_addrs_put(sdb, node_id, new_ni); if (group->group_type == TOPO_GROUP_TYPE_CHAT && group->channel_id[0]) { diff --git a/src/routing_layer/topo_node.c b/src/routing_layer/topo_node.c index e8010e4ac..3a65c8b65 100644 --- a/src/routing_layer/topo_node.c +++ b/src/routing_layer/topo_node.c @@ -11,6 +11,7 @@ #include "etcp.h" #include "config_parser.h" #include "topo_node.h" +#include #include "topo_group.h" #include "route_connectivity.h" #include "route_lib.h" @@ -426,6 +427,13 @@ int topo_node_build_sig_msg(struct TOPO_NODE* ni, uint8_t* buf, size_t buf_size) e = e->next; }} + uint8_t dg[DEVGROUP_WIRE_MAX]; + int bytes = devgroup_encode(&ni->devgroup, dg, sizeof(dg)); + if (bytes < 0 || off + 2 + (size_t)bytes > buf_size) { + DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: signature buffer too small"); return -1; + } + buf[off++] = (uint8_t)bytes; buf[off++] = (uint8_t)(bytes >> 8); + memcpy(buf + off, dg, (size_t)bytes); off += (size_t)bytes; return (int)off; } @@ -467,6 +475,7 @@ int topo_node_verify(const struct TOPO_NODE* ni) { if (!ni || !ni->timestamp || ni->timestamp > INT64_MAX || ni->node_id != sc_derive_node_id_from_pubkey(ni->public_key)) { DEBUG_WARN(DEBUG_CATEGORY_BGP, "invalid signed node identity or timestamp"); return -1; } + if (devgroup_verify(&ni->devgroup, ni->node_id, ni->public_key, ni->ed25519_public_key)) return -1; int len = topo_node_build_sig_msg((struct TOPO_NODE*)ni, msg, sizeof(msg)); if (len <= 0 || sc_ed25519_verify(ni->ed25519_public_key, msg, (size_t)len, ni->x25519_self_sig) != SC_OK) { DEBUG_WARN(DEBUG_CATEGORY_BGP, "node signature rejected: node=%016llx timestamp=%llu", @@ -483,12 +492,15 @@ int topo_node_dyn_size(const struct TOPOMSG_NODE* msg) { (unsigned long long)msg->node_id, msg->flags, msg->local_v4_subnets, msg->local_v6_subnets); return -1; } - return msg->node_name_len + if (msg->devgroup_size && (msg->devgroup_size <= 192 || msg->devgroup_size > DEVGROUP_WIRE_MAX)) { + DEBUG_WARN(DEBUG_CATEGORY_BGP, "devgroup: invalid NODEINFO block size=%u", msg->devgroup_size); return -1; + } + return msg->devgroup_size + msg->node_name_len + msg->local_v4_sockets * sizeof(struct TOPOMSG_SOCKMETA4) + msg->local_v4_addrs * sizeof(struct TOPOMSG_ADDR4) + msg->local_v6_sockets * sizeof(struct TOPOMSG_SOCKMETA6) + msg->local_v6_addrs * sizeof(struct TOPOMSG_ADDR6) - + msg->local_reality_socks * sizeof(struct TOPOMSG_REALITY_SOCK) + + msg->reality_size + msg->local_v4_subnets * sizeof(struct TOPOMSG_SUBNET4) + msg->local_v6_subnets * sizeof(struct TOPOMSG_SUBNET6) + msg->hop_count * 8; @@ -500,8 +512,12 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq, uint8_t* out, size_t out_max, uint16_t cumulative_rtt) { if (!ni || !nq || !out) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "NODEINFO encode: missing node/group/output"); return -1; } + uint8_t dg[DEVGROUP_WIRE_MAX]; + int dg_size = devgroup_encode(&ni->devgroup, dg, sizeof(dg)); + if (dg_size < 0) return -1; struct TOPOMSG_NODE msg; memset(&msg, 0, sizeof(msg)); + msg.devgroup_size = (uint16_t)dg_size; msg.flags = flags; msg.group_id = group_id; msg.node_id = ni->node_id; @@ -527,6 +543,14 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq, msg.local_v6_sockets = topo_list_count((struct _topo_head*)ni->v6_sock_meta); msg.local_v6_addrs = topo_list_count((struct _topo_head*)ni->v6_addrs); msg.local_reality_socks = topo_list_count((struct _topo_head*)ni->reality_socks); + size_t reality_size = 0; + for (struct TOPO_REALITY_SOCK* e = ni->reality_socks; e; e = e->next) { + size_t n = strnlen(e->server_name, sizeof(e->server_name)); + if (n >= sizeof(e->server_name)) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "REALITY name unterminated"); return -1; } + reality_size += sizeof(struct TOPOMSG_REALITY_SOCK) + n; + } + if (reality_size > UINT16_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "REALITY section too large"); return -1; } + msg.reality_size = (uint16_t)reality_size; if ((flags & TOPO_FLAG_SEND_SUBNETS) && nq->subnets) { msg.local_v4_subnets = topo_list_count((struct _topo_head*)nq->subnets->v4_subnets); msg.local_v6_subnets = topo_list_count((struct _topo_head*)nq->subnets->v6_subnets); @@ -551,6 +575,7 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq, uint8_t* dp = out + TOPOMSG_NODE_HDR_SIZE; if (msg.node_name_len) { memcpy(dp, ni->node_name, msg.node_name_len); dp += msg.node_name_len; } + memcpy(dp, dg, (size_t)dg_size); dp += dg_size; { struct TOPO_SOCKMETA4* e = ni->v4_sock_meta; while (e) { sockmeta_wire_write(dp, e->id, e->config_type, &e->address_profile); @@ -574,8 +599,9 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq, memcpy(w.short_id, e->short_id, 8); memcpy(w.server_pubkey, e->server_pubkey, 32); memcpy(w.version, e->version, 3); - memcpy(w.server_name, e->server_name, TOPO_REALITY_SNAME_MAX); - memcpy(dp, &w, sizeof(w)); dp += sizeof(w); e = e->next; + w.server_name_len = (uint8_t)strlen(e->server_name); + memcpy(dp, &w, sizeof(w)); dp += sizeof(w); + memcpy(dp, e->server_name, w.server_name_len); dp += w.server_name_len; e = e->next; }} if (msg.flags & TOPO_FLAG_SEND_SUBNETS) { if (nq->subnets) { @@ -646,6 +672,9 @@ int topo_node_deserialize(struct TOPO_GROUP* group, const uint8_t* data, size_t memcpy(ni->node_name, dp, msg->node_name_len); ni->node_name[msg->node_name_len] = 0; } dp += msg->node_name_len; + section = "devgroup"; + if (devgroup_decode(&ni->devgroup, dp, msg->devgroup_size)) goto invalid_profile; + dp += msg->devgroup_size; /* Списки сразу принадлежат ni: общий выход ошибки видит и незавершённую секцию. */ section = "v4 socket metadata"; @@ -694,16 +723,19 @@ int topo_node_deserialize(struct TOPO_GROUP* group, const uint8_t* data, size_t } section = "REALITY sockets"; { struct TOPO_REALITY_SOCK** tail = &ni->reality_socks; + const uint8_t* end = dp + msg->reality_size; for (index = 0; index < msg->local_reality_socks; index++) { + if ((size_t)(end - dp) < sizeof(struct TOPOMSG_REALITY_SOCK)) goto invalid_profile; const struct TOPOMSG_REALITY_SOCK* w = (const struct TOPOMSG_REALITY_SOCK*)dp; dp += sizeof(*w); + if ((size_t)(end - dp) < w->server_name_len || memchr(dp, 0, w->server_name_len)) goto invalid_profile; struct TOPO_REALITY_SOCK* e = u_calloc(1, sizeof(*e)); if (!e) goto no_memory; *tail = e; tail = &e->next; e->socket_id = w->socket_id; memcpy(e->short_id, w->short_id, 8); memcpy(e->server_pubkey, w->server_pubkey, 32); - memcpy(e->version, w->version, 3); memcpy(e->server_name, w->server_name, TOPO_REALITY_SNAME_MAX); - e->server_name[TOPO_REALITY_SNAME_MAX - 1] = 0; + memcpy(e->version, w->version, 3); memcpy(e->server_name, dp, w->server_name_len); dp += w->server_name_len; } + if (dp != end) goto invalid_profile; } section = "subnets"; index = 0; @@ -1098,6 +1130,10 @@ int topo_node_init_self(struct UTUN_INSTANCE* instance) { memcpy(ni->public_key, instance->my_keys.public_key, SC_PUBKEY_SIZE); memcpy(ni->ed25519_public_key, instance->my_ed25519_pubkey, SC_PUBKEY_SIZE); ni->node_name = u_strdup(instance->name); + uint8_t ed_private[32], x_private[32]; + int dg = instance->topo_sqlite_db ? devgroup_load_local(instance, &ni->devgroup, ed_private, x_private) : 0; + OPENSSL_cleanse(ed_private, sizeof(ed_private)); OPENSSL_cleanse(x_private, sizeof(x_private)); + if (dg < 0) { topo_node_destroy(instance->topo_groups, ni); return -1; } if (!ni->node_name || topo_node_sign_self(instance, ni) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "cannot initialize local identity"); topo_node_destroy(instance->topo_groups, ni); return -1; diff --git a/src/routing_layer/topo_node.h b/src/routing_layer/topo_node.h index d0fd382cd..8c55464c3 100644 --- a/src/routing_layer/topo_node.h +++ b/src/routing_layer/topo_node.h @@ -1,4 +1,4 @@ -/** +/** * @file topo_node.h * @brief Модель данных узла сети — структуры, сериализация, wire-формат. * @@ -20,6 +20,7 @@ extern "C" { #include "../lib/ll_queue.h" #include "secure_channel.h" #include "address_profile.h" +#include "devgroup.h" struct TOPO_GROUP; struct TOPO_GROUPS; @@ -120,20 +121,22 @@ struct TOPOMSG_SUBNET4 { uint8_t addr[4]; uint8_t prefix_length; } __attribute struct TOPOMSG_SUBNET6 { uint8_t addr[16]; uint8_t prefix_length; } __attribute__((packed)); /* Reality-камуфляж сокета: что нужно клиенту для подключения к reality TCP-сокету. - * short_id(8) + server_pubkey(32) + version(3) + server_name(фиксированный 256). */ + * short_id(8) + server_pubkey(32) + version(3) + server_name_len(1), затем server_name без NUL (до 255 байт). */ #define TOPO_REALITY_SNAME_MAX 256 struct TOPOMSG_REALITY_SOCK { uint8_t socket_id; uint8_t short_id[8]; uint8_t server_pubkey[32]; uint8_t version[3]; - uint8_t server_name[TOPO_REALITY_SNAME_MAX]; + uint8_t server_name_len; } __attribute__((packed)); -/* NODEINFO: этот заголовок, имя без NUL, SOCKMETA4/ADDR4/SOCKMETA6/ADDR6/REALITY, +/* NODEINFO: этот заголовок, имя без NUL, devgroup[devgroup_size], SOCKMETA4/ADDR4/SOCKMETA6/ADDR6/REALITY, * опциональные SUBNET4/SUBNET6, затем hop_count ID. Счётчики — число элементов, имя до 63 байт. * Многобайтные числа wire копируются в нативном порядке; IP — сетевые байты, port в памяти — host order. */ struct TOPOMSG_NODE { + uint16_t reality_size; /* Суммарные байты переменных REALITY-записей. */ + uint16_t devgroup_size; /* Блок devgroup после имени, 0 — отсутствует. */ uint8_t flags; uint64_t group_id, node_id; uint8_t ver; uint64_t timestamp; // версия всей подписанной записи, Unix microseconds uint8_t public_key[SC_PUBKEY_SIZE], ed25519_public_key[SC_PUBKEY_SIZE]; @@ -181,6 +184,7 @@ static inline int topo_list_count(struct _topo_head* head) { int n = 0; while (h /** Общая запись узла; node_registry хранит указатель на неё в отдельном ll_entry. */ struct TOPO_NODE { struct ll_entry ll; + struct devgroup_record devgroup; /* Общая подписанная принадлежность, владеет всеми байтами. */ uint32_t group_ref_count; // ссылки групп, ядра и временных пользователей uint64_t node_id; uint8_t ver; uint64_t timestamp; // 0 = неподписанные bootstrap-сведения diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index 8c4e7a679..c74c39200 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -392,6 +392,8 @@ int topo_node_sqlite_init(sqlite3* db) { return -1; } + if (devgroup_schema(db)) return -1; + /* Старые подписи непригодны; адреса и максимальные версии остаются для bootstrap и новой публикации. */ if (legacy_profile) { const char *migration = "SAVEPOINT compact_nat_profile;" @@ -1319,7 +1321,9 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) { if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 2, (sqlite3_int64)ni->timestamp); if (rc == SQLITE_OK) rc = sqlite3_bind_blob(stmt, 3, record, len, SQLITE_STATIC); if (rc == SQLITE_OK) rc = sqlite3_step(stmt); + int changed = rc == SQLITE_DONE ? sqlite3_changes(db) : 0; int final = sqlite3_finalize(stmt); + if (rc == SQLITE_DONE && final == SQLITE_OK && changed && devgroup_project(db, ni)) rc = SQLITE_ERROR; if (rc == SQLITE_DONE && final == SQLITE_OK) { rc = sqlite3_exec(db, "RELEASE node_snapshot", NULL, NULL, NULL); if (rc == SQLITE_OK) return 0; diff --git a/src/transport_layer/etcp_api.h b/src/transport_layer/etcp_api.h index 2470306a9..823767485 100644 --- a/src/transport_layer/etcp_api.h +++ b/src/transport_layer/etcp_api.h @@ -78,6 +78,7 @@ extern "C" { #define ETCP_RT_ID_CALL_AUDIO 0x3F // аудио, независимо от сигналинга/видео #define ETCP_RT_ID_RADIO_CALL 0x40 // адресное приглашение участника в рацию CHAT-канала #define ETCP_RT_ID_MULTICAST_REPORT 0x41 // отчёты о переполнении multicast FIFO +#define ETCP_RT_ID_DEVGROUP 0x43 // объединение устройств, E2E приглашения #define ETCP_RT_ID_ATTACHMENT_KEYS 0x42 // запросы и ручная E2E-выдача ключей групповых вложений // Connection status events (instance-level callback). diff --git a/src/utun_instance.c b/src/utun_instance.c index afc22f387..b5074e3b1 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -1,6 +1,7 @@ // utun_instance.c - Root instance implementation #include "utun_instance.h" #include "chat/chat_local.h" +#include "chat/chat_devgroup.h" #include "stream/stream.h" #include "etcp_router.h" #include "multicast.h" @@ -854,7 +855,8 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { snprintf(db_file, sizeof(db_file), "%s/chats.db", instance->config->global.db_path); if (db_sync_enable(instance) < 0) return -1; topo_group_invite_init(instance); - if (chat_core_init(instance, db_file) < 0 || chat_sync_init(instance) < 0 || stream_service_init(instance) < 0) goto fail; + if (chat_core_init(instance, db_file) < 0 || chat_sync_init(instance) < 0 || + chat_devgroup_init(instance) < 0 || stream_service_init(instance) < 0) goto fail; /* Имя из конфига публикуем подписанным member-блоком после восстановления Merkle. */ if (instance->config->global.chatserver_enabled) chat_core_update_my_member(instance); instance->media_async = media_async_create(); @@ -909,6 +911,7 @@ fail: void chat_service_stop(struct UTUN_INSTANCE* instance) { if (!instance) return; instance->chat_started = 0; + chat_devgroup_destroy(instance); chat_sync_quiesce(instance); chat_local_stop(instance); radio_call_destroy(instance); diff --git a/tests/Makefile.am b/tests/Makefile.am index 525c4f601..f4f3bbfbc 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -1482,3 +1482,12 @@ test_etcp_driver_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_etcp_tx_rto_SOURCES = test_etcp_tx_rto.c $(test_clock_sources) test_etcp_tx_rto_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_etcp_tx_rto_LDFLAGS = $(test_clock_ldflags) + +check_PROGRAMS += test_devgroup +test_devgroup_SOURCES = test_devgroup.c +test_devgroup_LDADD = $(LIBUTUN) $(COMMON_LIBS) + +check_PROGRAMS += test_devgroup_network +test_devgroup_network_SOURCES = test_devgroup_network.c +test_devgroup_network_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_devgroup_network_LDFLAGS = -Wl,--wrap=etcp_route_send diff --git a/tests/test_chat_headless_control.c b/tests/test_chat_headless_control.c index e88a15e77..7bbf96fe4 100644 --- a/tests/test_chat_headless_control.c +++ b/tests/test_chat_headless_control.c @@ -117,6 +117,14 @@ int main(void) { assert(!hc_response_ok(inst->topo_sqlite_db, response, 10)); unsigned old_observed = observed; chat_event_post(inst, CHAT_EVT_STATUS_REFRESH, NULL, 0); assert(observed == old_observed + 1); hc_request_line(inst, peer, "{\"id\":11,\"cmd\":\"status\"}\n", response); assert(hc_response_ok(inst->topo_sqlite_db, response, 11)); + hc_request_line(inst, peer, "{\"id\":30,\"cmd\":\"devgroup_rename\",\"name\":\"My devices\"}\n", response); + assert(hc_response_ok(inst->topo_sqlite_db, response, 30) && strstr(response, "My devices")); + hc_request_line(inst, peer, "{\"id\":31,\"cmd\":\"devgroup_status\"}\n", response); + assert(hc_response_ok(inst->topo_sqlite_db, response, 31) && strstr(response, "My devices") && !strstr(response, "pin")); + hc_request_line(inst, peer, "{\"id\":32,\"cmd\":\"devgroup_invite\",\"ch\":\"1\",\"peer\":\"invalid\"}\n", response); + assert(!hc_response_ok(inst->topo_sqlite_db, response, 32)); + hc_request_line(inst, peer, "{\"id\":33,\"cmd\":\"devgroup_leave\"}\n", response); + assert(hc_response_ok(inst->topo_sqlite_db, response, 33) && !strstr(response, "My devices")); uint64_t channel = chat_core_create_channel_auto(inst, "media"); assert(channel); hc_request_line(inst, peer, "{\"id\":20,\"cmd\":\"server_link\"}\n", response); assert(hc_response_ok(inst->topo_sqlite_db, response, 20)); diff --git a/tests/test_devgroup.c b/tests/test_devgroup.c new file mode 100644 index 000000000..476dec5c0 --- /dev/null +++ b/tests/test_devgroup.c @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include "utun_instance.h" +#include "routing_layer/topo_group.h" +#include "routing_layer/topo_node_sqlite.h" +#include "chat/chat_devgroup.h" +#include "../lib/u_async.h" +#include "../lib/mem.h" + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_DEBUG); + struct UTUN_INSTANCE inst = {0}; struct TOPO_GROUPS groups = {.instance = &inst}; inst.topo_groups = &groups; + inst.ua = uasync_create(); assert(inst.ua); + groups.node_registry = queue_new(inst.ua, 16, 0, 8, "devgroup_nodes"); + groups.group_list = queue_new(inst.ua, 0, 0, 0, "devgroup_groups"); + assert(groups.node_registry && groups.group_list); + assert(sc_generate_keypair(&inst.my_keys) == SC_OK); + uint8_t xpub[32]; + memcpy(inst.my_ed25519_privkey, inst.my_keys.private_key, 32); + assert(!devgroup_public_keys(inst.my_ed25519_privkey, inst.my_keys.private_key, inst.my_ed25519_pubkey, xpub)); + inst.node_id = sc_derive_node_id_from_pubkey(inst.my_keys.public_key); strcpy(inst.name, "device"); + assert(sqlite3_open(":memory:", &inst.topo_sqlite_db) == SQLITE_OK); + assert(!topo_node_sqlite_init(inst.topo_sqlite_db)); assert(!topo_node_init_self(&inst)); + assert(!chat_devgroup_rename(&inst, "Мои устройства")); + struct devgroup_record r, loaded; uint8_t ep[32], xp[32]; struct devgroup_profile p; + assert(devgroup_load_local(&inst, &r, ep, xp) == 1); + assert(memcmp(ep, xp, 32) && !devgroup_profile_read(&r, &p) && p.version == 1); + struct TOPO_NODE* node = topo_node_registry_find(&groups, inst.node_id); + uint64_t before = node->timestamp; + assert(!chat_devgroup_rename(&inst, "Мои устройства") && node->timestamp == before); + assert(!devgroup_verify(&r, inst.node_id, inst.my_keys.public_key, inst.my_ed25519_pubkey)); + assert(devgroup_verify(&r, inst.node_id + 1, inst.my_keys.public_key, inst.my_ed25519_pubkey) < 0); + r.json[10] ^= 1; assert(devgroup_profile_read(&r, &p) < 0); r.json[10] ^= 1; + struct devgroup_record conflict = r; struct devgroup_profile original, changed; + assert(!devgroup_profile_read(&r, &original)); + assert(!devgroup_sign(&conflict, ep, "Concurrent name", original.version, original.timestamp, + inst.node_id, inst.my_keys.public_key, inst.my_ed25519_pubkey)); + assert(!devgroup_profile_read(&conflict, &changed)); + assert((devgroup_profile_compare(&original, &changed) > 0) == (memcmp(original.hash, changed.hash, 32) > 0)); + changed.version++; assert(devgroup_profile_compare(&changed, &original) > 0); + changed.version--; changed.timestamp++; assert(devgroup_profile_compare(&changed, &original) > 0); + assert(devgroup_decode(&conflict, (const uint8_t*)"", 192) < 0); + uint8_t wire[TOPO_NODE_WIRE_MAX_SIZE]; struct TOPO_GROUP_NODE nq = {0}; + int bytes = topo_node_serialize(node, &nq, 0, 0, wire, sizeof(wire), 0); assert(bytes > 0); + struct TOPO_GROUP group = {.instance = &inst}; struct TOPO_NODE* parsed = NULL; + assert(!topo_node_deserialize(&group, wire, bytes, &parsed, NULL, NULL, NULL, NULL)); + assert(!topo_node_verify(parsed) && !memcmp(&parsed->devgroup, &r, sizeof(r))); topo_node_destroy(&groups, parsed); + assert(devgroup_latest(inst.topo_sqlite_db, r.ed, &loaded, &p) == 1 && p.version == 1); + assert(sqlite3_exec(inst.topo_sqlite_db, "CREATE TRIGGER deny_devgroup BEFORE UPDATE ON nodes BEGIN SELECT RAISE(ABORT,'test'); END", + NULL, NULL, NULL) == SQLITE_OK); + assert(chat_devgroup_rename(&inst, "Rejected") < 0 && node->timestamp == before); + assert(devgroup_load_local(&inst, &loaded, ep, xp) == 1 && !strcmp(loaded.json, r.json)); + assert(sqlite3_exec(inst.topo_sqlite_db, "DROP TRIGGER deny_devgroup", NULL, NULL, NULL) == SQLITE_OK); + assert(!chat_devgroup_rename(&inst, "New name")); + assert(devgroup_load_local(&inst, &loaded, ep, xp) == 1 && !devgroup_profile_read(&loaded, &p) && p.version == 2); + /* Новый runtime восстанавливает обе пары и принадлежность до первой публикации. */ + topo_node_registry_unref(&groups, inst.node_id); assert(!topo_node_init_self(&inst)); + node = topo_node_registry_find(&groups, inst.node_id); assert(!strcmp(node->devgroup.json, loaded.json)); + uint8_t receipt[16] = {1}; + assert(!chat_devgroup_import(&inst, &loaded, ep, xp, receipt, 42)); + assert(chat_devgroup_receipt(&inst, receipt, 42) == 1); + assert(!chat_devgroup_leave(&inst) && !node->devgroup.json[0]); + assert(!chat_devgroup_import(&inst, &loaded, ep, xp, receipt, 42) && !node->devgroup.json[0]); + assert(devgroup_latest(inst.topo_sqlite_db, r.ed, &loaded, &p) == 0); + assert(!chat_devgroup_leave(&inst)); + topo_node_registry_unref(&groups, inst.node_id); assert(!topo_node_init_self(&inst)); + node = topo_node_registry_find(&groups, inst.node_id); assert(!node->devgroup.json[0]); + topo_node_registry_unref(&groups, inst.node_id); queue_free(groups.node_registry); queue_free(groups.group_list); + assert(sqlite3_close(inst.topo_sqlite_db) == SQLITE_OK); uasync_destroy(inst.ua, 0); + puts("PASS devgroup: signatures, wire, transactional projection, restart, rename, leave and late import replay"); return 0; +} diff --git a/tests/test_devgroup_network.c b/tests/test_devgroup_network.c new file mode 100644 index 000000000..967e167db --- /dev/null +++ b/tests/test_devgroup_network.c @@ -0,0 +1,212 @@ +/* Настоящий router/UDP A ↔ B ↔ C: devgroup E2E, повтор PIN, потеря ACK, NODEINFO и выход. */ +#include "utun_instance.h" +#include "chat/chat_core.h" +#include "chat/chat_devgroup.h" +#include "chat/chat_member.h" +#include "routing_layer/route_crypto.h" + +#include "chat/member_sync.h" +#include "chat/merkle_sync.h" +#include "chat/db_sync.h" +#include "routing_layer/topo_group.h" +#include "routing_layer/topo_node_sqlite.h" +#include "transport_layer/node_conn_direct.h" +#include "transport_layer/etcp_connections.h" +#include "transport_layer/etcp.h" +#include "../lib/platform_compat.h" +#include "../lib/socket_compat.h" +#include "../lib/debug_config.h" +#include "test_net.h" +#include +#include +#include +#include + +static struct UASYNC* ua; +static struct UTUN_INSTANCE* nodes[4]; +static char channel[21]; + +static void hex(const uint8_t* data, size_t len, char* out) { + for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", data[i]); +} + +static struct UTUN_INSTANCE* create_node(unsigned port) { + struct SC_MYKEYS keys; assert(sc_generate_keypair(&keys) == SC_OK); + char private_key[65], public_key[65], config[1024]; hex(keys.private_key, 32, private_key); hex(keys.public_key, 32, public_key); + snprintf(config, sizeof(config), + "[global]\nmy_private_key=%s\nmy_public_key=%s\nkeepalive_adaptive=0\n" + "[server: udp]\naddr=127.0.0.1:%u\ntype=public\n[allowed_keys]\nallow_all=1\n" + "[ntp]\nenabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", private_key, public_key, port); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); + assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); return inst; +} + +/* Все три заранее принятых участника имеют настоящие подписи root/join. */ +static void seed_members(uint8_t channel_x[32], uint8_t channel_ed[32], uint8_t owner_private[32], int count) { + for (int dst = 0; dst < count; dst++) for (int author = 0; author < count; author++) { + uint8_t body[112], join[64], tree[64]; + assert(member_sync_build_join_msg(channel_x, channel_ed, nodes[author]->node_id, + nodes[author]->my_keys.public_key, 1, body, sizeof(body)) == 112); + assert(sc_ed25519_sign(nodes[author]->my_ed25519_privkey, body, sizeof(body), join) == SC_OK); + assert(member_sync_sign_pubkey(owner_private, nodes[author]->my_keys.public_key, tree) == 0); + assert(member_sync_put(nodes[dst], channel, nodes[author]->node_id, nodes[author]->my_keys.public_key, + nodes[author]->my_ed25519_pubkey, join, 1, NULL, 0, "{}", NULL, NULL, 0, 0, tree) >= 0); + } +} + +/* Поднимаем транспорт только соседних пар; C получает данные A через B. */ +static struct NODE_CONN_DIRECT* connect_node(int from, int to, unsigned port) { + struct TOPO_ADDR4 address = { .addr = {127, 0, 0, 1}, .port = port, .protocol = TOPO_PROTO_UDP }; + struct TOPO_NODE node = { .node_id = nodes[to]->node_id, .v4_addrs = &address }; + memcpy(node.public_key, nodes[to]->my_keys.public_key, 32); struct NODE_CONN_DIRECT* handle = NULL; + assert(node_conn_direct_open_node(nodes[from], node.node_id, NULL, NULL, &handle, &node, NULL) != NCD_ERR); return handle; +} + +static int wait_ready(struct TOPO_PEER_REQUEST* handle) { + uint64_t deadline = get_time_tb() + 15u * 10000u; + while (get_time_tb() < deadline) { + if (topo_group_peer_phase(handle) == TOPO_PEER_READY) return 1; + uasync_poll(ua, 5); + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: group readiness timeout phase=%d", topo_group_peer_phase(handle)); return 0; +} + +/* C открывает групповой запрос после входящего UP; до него адрес B ещё не получен. */ +static int wait_transport(struct NODE_CONN_DIRECT* ab, struct NODE_CONN_DIRECT* bc) { + struct ETCP_CONN *a = NULL, *b = NULL; + uint64_t deadline = get_time_tb() + 15u * 10000u; + while (get_time_tb() < deadline) { + a = node_conn_direct_get_conn(ab); b = node_conn_direct_get_conn(bc); + struct ETCP_CONN* c = instance_find_conn(nodes[2], nodes[1]->node_id); + if (a && b && c && a->initialized && b->initialized && c->initialized + && a->links_up && b->links_up && c->links_up) return 1; + uasync_poll(ua, 5); + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: bootstrap timeout ab=%p bc=%p", (void*)a, (void*)b); return 0; +} + + +static struct ll_entry* held_keys; +static int hold_keys; +static int drop_pin_result = 1, drop_key_ack = 1, duplicate_pin, key_sends; +int __real_etcp_route_send(struct UTUN_INSTANCE*, uint64_t, uint64_t, struct ll_entry*, int, int); +int __wrap_etcp_route_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, struct ll_entry* entry, int ttl, int crypto) { + if (entry->len >= 18 && entry->dgram[0] == ETCP_RT_ID_DEVGROUP) { + assert(crypto == (ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)); + if (entry->dgram[1] == 2) duplicate_pin++; + if (entry->dgram[1] == 3) { + key_sends++; + if (hold_keys && !held_keys) { held_keys = entry; return 0; } + } + if (entry->dgram[1] == 4 && entry->len == 20 && + ((entry->dgram[19] == 1 && drop_pin_result-- > 0) || (entry->dgram[19] == 0 && drop_key_ack-- > 0))) { + queue_dgram_free(entry); queue_entry_free(entry); return 0; + } + } + return __real_etcp_route_send(inst, group, peer, entry, ttl, crypto); +} +static int state(int node, const char* id) { + char* json = chat_devgroup_status(nodes[node]); assert(json); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(nodes[node]->topo_sqlite_db, + "SELECT json_extract(value,'$.state') FROM json_each(?,'$.invitations') WHERE json_extract(value,'$.id')=?", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC); + int result = sqlite3_step(st) == SQLITE_ROW ? sqlite3_column_int(st, 0) : 0; + sqlite3_finalize(st); u_free(json); return result; +} +static void wait_state(int node, const char* id, int expected) { + uint64_t until = get_time_tb() + 150000; + while (get_time_tb() < until) { if (state(node, id) == expected) return; uasync_poll(ua, 5); } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: timeout node=%d state=%d expected=%d", node, state(node, id), expected); abort(); +} +static void wait_profile(int node, uint64_t peer, const char* name) { + uint64_t until = get_time_tb() + 150000; + while (get_time_tb() < until) { + uint8_t id[32]; char actual[64]; + int rc = devgroup_member(nodes[node]->topo_sqlite_db, peer, id, actual); + assert(rc >= 0); if (!strcmp(actual, name)) return; uasync_poll(ua, 5); + } + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: profile timeout node=%d peer=%016llx", node, (unsigned long long)peer); abort(); +} +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_MEMBER_SYNC, DEBUG_LEVEL_INFO); + ua = uasync_create(); assert(ua); unsigned ports[4]; + for (int i = 0; i < 3; i++) { + nodes[i] = create_node(0); + ports[i] = ss_get_port(&test_etcp_bound_socket(nodes[i], "udp")->local_addr); + } + chat_core_create_channel_auto(nodes[0], "Devices"); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(nodes[0]->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW); snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st); + uint8_t x[32], ed[32], signature[64], owner_private[32]; + assert(topo_node_sqlite_channel_get(nodes[0]->topo_sqlite_db, channel, NULL, 0, NULL, x, ed, signature) == 0); + assert(topo_node_sqlite_channel_get_priv(nodes[0]->topo_sqlite_db, channel, owner_private) == 0); + for (int i = 1; i < 3; i++) { + assert(topo_node_sqlite_channel_put(nodes[i]->topo_sqlite_db, channel, "Devices", nodes[0]->node_id, + x, NULL, ed, NULL, signature) == 0); chat_core_ensure_channel_ready(nodes[i], channel); + } + seed_members(x, ed, owner_private, 3); + struct NODE_CONN_DIRECT* ab = connect_node(0, 1, ports[1]), *bc = connect_node(1, 2, ports[2]); + assert(wait_transport(ab, bc)); + struct TOPO_PEER_REQUEST *groups[5] = {0}; uint64_t gid = strtoull(channel, NULL, 10); + assert(topo_group_peer_open(topo_groups_find(nodes[0]->topo_groups, gid), nodes[1]->node_id, &groups[0]) == 0); + assert(topo_group_peer_open(topo_groups_find(nodes[1]->topo_groups, gid), nodes[2]->node_id, &groups[1]) == 0); + assert(topo_group_peer_open(topo_groups_find(nodes[2]->topo_groups, gid), nodes[1]->node_id, &groups[2]) == 0); + for (int i = 0; i < 3; i++) assert(wait_ready(groups[i])); + /* Ждём снимок C через B до выбора C как известного участника. */ + uint64_t until = get_time_tb() + 150000; + while (!topo_node_registry_find(nodes[0]->topo_groups, nodes[2]->node_id) && get_time_tb() < until) uasync_poll(ua, 5); + char id[33], pin[5], wrong[5]; + assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1); + strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0'; + assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, 1); + assert(duplicate_pin >= 2 && state(0, id) == 1); + assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4); wait_state(0, id, 4); + assert(key_sends >= 2); + struct devgroup_record r[3]; uint8_t ep[3][32], xp[3][32]; + assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1); + assert(devgroup_load_local(nodes[1], &r[1], ep[1], xp[1]) == 0); + assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 1); + assert(!memcmp(ep[0], ep[2], 32) && !memcmp(xp[0], xp[2], 32)); + assert(!chat_devgroup_rename(nodes[2], "New device group")); + wait_profile(0, nodes[2]->node_id, "New device group"); + until = get_time_tb() + 150000; + do { + assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1); + if (!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json)) break; + uasync_poll(ua, 5); + } while (get_time_tb() < until); + assert(!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json)); + wait_profile(1, nodes[0]->node_id, "New device group"); + assert(!chat_devgroup_leave(nodes[2])); wait_profile(0, nodes[2]->node_id, ""); + /* После успешного импорта новый ACK может потеряться прямо перед выходом. */ + drop_key_ack = 1; + assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1); + assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4); + assert(!chat_devgroup_leave(nodes[2])); wait_state(0, id, 4); + assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0); + /* Три разные неверные попытки завершают операцию. */ + assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1); + strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0'; + for (int i = 0; i < 3; i++) { assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, i == 2 ? 5 : 1); } + assert(state(0, id) == 5 && chat_devgroup_accept(nodes[2], id, pin) < 0); + /* Выход до первого commit тоже отменяет согласие: поздние ключи не должны присоединить C. */ + hold_keys = 1; + assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1); + assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(0, id, 3); assert(held_keys); + assert(!chat_devgroup_leave(nodes[2]) && state(2, id) == 5); hold_keys = 0; + assert(!__real_etcp_route_send(nodes[0], gid, nodes[2]->node_id, held_keys, 0, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)); + held_keys = NULL; + until = get_time_tb() + 5000; while (get_time_tb() < until) uasync_poll(ua, 5); + assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0); + assert(!chat_devgroup_leave(nodes[0]) && state(0, id) == 7); + for (int i = 0; i < 3; i++) topo_group_peer_close(groups[i]); + node_conn_direct_close(ab); node_conn_direct_close(bc); + for (int i = 0; i < 3; i++) utun_instance_destroy(nodes[i]); + uasync_poll(ua, 0); uasync_destroy(ua, 0); + puts("PASS devgroup: encrypted signed relay, PIN retries, ACK loss, NODEINFO convergence, leave/replay and three attempts"); + return 0; +} diff --git a/tests/test_topo_node_wire.c b/tests/test_topo_node_wire.c index 98c3dce19..80c91f98b 100644 --- a/tests/test_topo_node_wire.c +++ b/tests/test_topo_node_wire.c @@ -136,9 +136,10 @@ static void fixture_packet(struct wire_fixture *f, uint8_t flags, uint8_t hops) a.addr[15] = i + 1; memcpy(p, &a, sizeof(a)); p += sizeof(a); } for (unsigned i = 0; i < 2; i++) { - struct TOPOMSG_REALITY_SOCK r = {.socket_id = i}; - memcpy(r.server_name, i ? "second.test" : "first.test", i ? 12 : 11); + struct TOPOMSG_REALITY_SOCK r = {.socket_id = i, .server_name_len = i ? 11 : 10}; memcpy(p, &r, sizeof(r)); p += sizeof(r); + memcpy(p, i ? "second.test" : "first.test", r.server_name_len); p += r.server_name_len; + h->reality_size += sizeof(r) + r.server_name_len; } f->subnet4_offset = p - f->wire; if (flags & TOPO_FLAG_SEND_SUBNETS) for (unsigned i = 0; i < 2; i++) { @@ -200,9 +201,34 @@ static void test_invalid_packets(struct wire_fixture *f) { } struct TOPOMSG_SOCKMETA4 *meta = (struct TOPOMSG_SOCKMETA4 *)(f->wire + TOPOMSG_NODE_HDR_SIZE + 4); meta[1].mapping = UINT8_MAX; expect_rejected(f, f->length, 1); + fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2); + size_t reality = f->subnet4_offset - h->reality_size; + struct TOPOMSG_REALITY_SOCK* r = (struct TOPOMSG_REALITY_SOCK*)(f->wire + reality); + r->server_name_len = 255; expect_rejected(f, f->length, 1); + fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2); + f->wire[reality + sizeof(*r)] = 0; expect_rejected(f, f->length, 1); + fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2); + h->local_reality_socks = 0; expect_rejected(f, f->length, 1); + fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2); + assert(h->reality_size == 2 * sizeof(*r) + 21); // Имена10+11, без прежних256 байт на каждое. DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] NODEINFO malformed counters, all truncations, tail, prefixes and partial metadata"); } +static void test_compact_reality(struct wire_fixture* f) { + fixture_packet(f, 0, 0); + struct TOPO_NODE *node = NULL, *copy = NULL; + assert(!topo_node_deserialize(&f->group, f->wire, f->length, &node, NULL, NULL, NULL, NULL)); + memset(node->reality_socks->server_name, 'a', 255); node->reality_socks->server_name[255] = 0; + struct TOPO_GROUP_NODE nq = {0}; uint8_t wire[2048]; + int n = topo_node_serialize(node, &nq, 0, 0, wire, sizeof(wire), 0); + assert(n == (int)f->length + 245); // Имя выросло с10 до255, прочее неизменно. + assert(!topo_node_deserialize(&f->group, wire, n, ©, NULL, NULL, NULL, NULL)); + assert(strlen(copy->reality_socks->server_name) == 255); + assert(!strcmp(copy->reality_socks->next->server_name, "second.test")); + topo_node_destroy(&f->groups, node); topo_node_destroy(&f->groups, copy); + DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] compact REALITY: exact size and255-byte SNI roundtrip"); +} + static void test_optional_outputs(struct wire_fixture *f) { for (unsigned with_subnets = 0; with_subnets < 2; with_subnets++) for (unsigned with_hops = 0; with_hops < 2; with_hops++) { fixture_packet(f, with_subnets ? TOPO_FLAG_SEND_SUBNETS : 0, with_hops ? 2 : 0); @@ -501,6 +527,7 @@ int main(void) { struct wire_fixture fixture; fixture_init(&fixture); test_invalid_packets(&fixture); + test_compact_reality(&fixture); test_optional_outputs(&fixture); test_encoder(&fixture); test_empty_and_arguments(&fixture); diff --git a/tools/chatcli b/tools/chatcli index 15b300238..503d53e65 100755 --- a/tools/chatcli +++ b/tools/chatcli @@ -247,6 +247,19 @@ def main(): try: if cmd == "ping": cmd_ping() elif cmd in ("poll", "vote", "react", "feedback", "reactions"): cmd_interaction(cmd, args[1:]) + elif cmd == "devgroup": + parser = argparse.ArgumentParser(prog="chatcli devgroup") + sub = parser.add_subparsers(dest="action", required=True) + sub.add_parser("status"); sub.add_parser("leave") + rename = sub.add_parser("rename"); rename.add_argument("name") + invite = sub.add_parser("invite"); invite.add_argument("ch"); invite.add_argument("peer") + accept = sub.add_parser("accept"); accept.add_argument("invite") + opts = vars(parser.parse_args(args[1:])); action = opts.pop("action") + if action == "accept": + import getpass + opts["pin"] = getpass.getpass("PIN from the inviting device: ") + if action == "invite": opts["peer"] = f"{int(opts['peer'], 16):016x}" + _req("devgroup_" + action, **opts) elif cmd == "status": cmd_status() elif cmd == "channels": cmd_channels() elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members "),)) diff --git a/tools/chatcli_commands.txt b/tools/chatcli_commands.txt index 9bbe7db6f..6b3c5ee01 100644 --- a/tools/chatcli_commands.txt +++ b/tools/chatcli_commands.txt @@ -142,3 +142,14 @@ listen --------------------- chatcli --host 192.168.1.1 --port 8888 channels CHATCLI_HOST=10.0.0.1 CHATCLI_PORT=7777 chatcli status + +Группа устройств (devgroup): + devgroup status своя группа и состояния приглашений + devgroup rename "Имя" создать группу либо переименовать свою + devgroup invite пригласить известное устройство; ответ содержит PIN + devgroup accept ввод PIN с экрана приглашающего устройства (без argv) + devgroup leave выйти, удалить локальные ключи и отменить ожидания +Headless JSON: cmd=devgroup_status/rename/invite/accept/leave; параметры name, ch+peer, +invite+pin соответственно. peer — 16 строчных hex-цифр, invite — 32. PIN не выводится в status. +Состояния:1 ждёт PIN,2 проверяет,3 передаёт ключи,4 импорт подтверждён,5 отказ,6 истёк,7 результат неизвестен. +Остановка сервиса отменяет ожидания; сохранённые ключи и квитанции импорта переживают перезапуск. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 6d4946074..f5bfe92df 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -51,9 +51,10 @@ object AppEventHandler : ChatEventCallback { if (type == 13 && !NativeLib.isCoreStopping()) deliver("service stop") { com.utun.chat.headless.HeadlessService.onCoreStopped() } if (type == 12 || type == 13 || type == 65) deliver("radio call") { com.utun.chat.data.RadioCallController.onEvent(type, data) } + if (type == 13 || type == 70 || type == 71) deliver("devgroup") { com.utun.chat.data.DevgroupRepository.onEvent(type, data) } if (type == 13 || type in 61..64) deliver("discovery") { com.utun.chat.data.LocalDiscovery.onEvent(type, data) } if (type == 13 || type in 49..50) deliver("member files") { ChatApplication.instance.memberFiles.onEvent(type, data) } - if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) { + if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, 70, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) { deliver("members") { ChatApplication.instance.members.onEvent(type, data) } deliver("avatars") { ChatApplication.instance.avatars.onEvent(type, data) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 901009699..002a37eb7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -46,7 +46,8 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = val directPresence: Boolean = false, val indirectPresence: Boolean = false, val bgpPresence: Boolean = false, val bgpUp: Boolean = false, val bgpRtt: Int = 0, val bgpNodes: Int = 0, val connecting: Boolean = false, - val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false) { + val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false, + val devgroup: String = "", val devgroupName: String = "") { val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0) } @@ -161,14 +162,15 @@ fun parseConnList(data: ByteArray): List { } /* Размер общего снимка из chat_member.h; одинаковый для desktop и Android. */ -const val MEMBER_WIRE_SIZE = 164 +const val MEMBER_WIRE_SIZE = 260 data class MemberWire( val nodeId: Long, val online: Int, val isSelf: Boolean, val flags: Int, val rtt: Int, val natType: Int, val storage: Boolean, val name: String, val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false, val verified: Boolean = false, val nodeType: Int = 0, - val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0 + val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0, + val devgroup: String = "", val devgroupName: String = "" ) fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? { @@ -192,13 +194,17 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? { val presence = buf.get().toInt() and 0xff val up = buf.get().toInt() and 0xff val intermediaries = buf.short.toInt() and 0xffff + val groupBytes = ByteArray(32); buf.get(groupBytes) + val devgroup = if (groupBytes.all { it == 0.toByte() }) "" else groupBytes.joinToString("") { "%02x".format(it.toInt() and 255) } + val groupName = ByteArray(64); buf.get(groupName) + val devgroupName = String(groupName, Charsets.UTF_8).substringBefore('\u0000') return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile, - storageRole, verified, nodeType, presence, up, intermediaries) + storageRole, verified, nodeType, presence, up, intermediaries, devgroup, devgroupName) } /* Полный снимок заменяет все свойства; connecting — локальное состояние запроса GUI. */ fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): ChatMember = previous.copy( - name = name, online = online != 0, + name = name, online = online != 0, devgroup = devgroup, devgroupName = devgroupName, isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0, isModer = (flags and 4) != 0, isStorage = storage, isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, radioMuted = (flags and 0x20) != 0, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt new file mode 100644 index 000000000..408c3bcff --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt @@ -0,0 +1,41 @@ +package com.utun.chat.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import java.nio.ByteBuffer +import java.nio.ByteOrder + +data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int) +data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List = emptyList(), + val pin: String = "", val failed: Boolean = false) + +/** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */ +object DevgroupRepository { + private val mutable = MutableStateFlow(DevgroupState()) + val state = mutable.asStateFlow() + private var nextId = 0L + private val pending = mutableMapOf() + fun command(action: Int = 0, group: Long = 0, peer: Long = 0, text: String = "", pin: String = "") { + if (action == 0 && pending.containsValue(0)) return + val id = ++nextId + if (NativeLib.devgroupCommand(action, group, peer, text, pin, id)) pending[id] = action + else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true) } + } + fun dismiss() { mutable.value = mutable.value.copy(pin = "", failed = false) } + fun onEvent(type: Int, data: ByteArray?) { + if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return } + if (type == 70) { command(); return } + if (type != 71 || data == null || data.size < 9) return + val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long + val action = pending.remove(id) ?: return + if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true); return } + val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8)) + if (action == 1) { mutable.value = mutable.value.copy(pin = json.getString("pin"), failed = false); return } + val invitations = json.getJSONArray("invitations") + mutable.value = mutable.value.copy(name = json.getString("name"), member = json.getString("group").isNotEmpty(), + invitations = List(invitations.length()) { i -> val entry = invitations.getJSONObject(i) + DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state")) }, + failed = if (action == 0) mutable.value.failed else false) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberRepository.kt index a49d3e601..e5584dc27 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberRepository.kt @@ -55,6 +55,7 @@ class MemberRepository( active = false; generation++; loaded.clear(); pending.clear(); _members.value = emptyMap() log("INFO", "Core stopped: member snapshots cleared generation=$generation"); return } + if (type == 70) { loaded.toList().forEach(::refresh); return } if (type !in listOf(23, 24, 25, 31)) return if (!active) { log("DEBUG", "Ignored member event after stop type=$type"); return } try { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 02841720d..23f7bfced 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -491,6 +491,9 @@ object NativeLib { private external fun nativeConnectMemberNode(channelId: String, nodeId: Long) private external fun nativeGetChannels(): String? private external fun nativeGetMessages(channelId: String, limit: Int, anchor: ByteArray, exact: Boolean): String? + fun devgroupCommand(action: Int, group: Long, peer: Long, text: String, pin: String, requestId: Long): Boolean = + nativeDevgroupCommand(action, group, peer, text.toByteArray(Charsets.UTF_8), pin.toByteArray(Charsets.UTF_8), requestId) + private external fun nativeDevgroupCommand(action: Int, group: Long, peer: Long, text: ByteArray, pin: ByteArray, requestId: Long): Boolean private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean private external fun nativeSetDebugLevel(category: String, level: String) private external fun nativeGetDebugCategories(): String? diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt new file mode 100644 index 000000000..a0a3ca1c5 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt @@ -0,0 +1,52 @@ +package com.utun.chat.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.utun.chat.R +import com.utun.chat.data.DevgroupRepository +import kotlinx.coroutines.delay + +@Composable +fun DevgroupDialog(channel: String, peer: Long = 0, onDismiss: () -> Unit) { + val state by DevgroupRepository.state.collectAsState() + var name by remember { mutableStateOf("") } + var invite by remember { mutableStateOf("") } + var pin by remember { mutableStateOf("") } + LaunchedEffect(Unit) { while (true) { DevgroupRepository.command(); delay(1000) } } + DisposableEffect(Unit) { onDispose { DevgroupRepository.dismiss() } } + AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.devgroup_title)) }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(if (state.member) state.name else stringResource(R.string.devgroup_none)) + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.devgroup_name)) }) + TextButton(onClick = { DevgroupRepository.command(3, text = name) }) { Text(stringResource(R.string.devgroup_rename)) } + if (peer != 0L) TextButton(onClick = { DevgroupRepository.command(1, channel.toULongOrNull()?.toLong() ?: 0, peer) }) { + Text(stringResource(R.string.devgroup_add, peer.toULong().toString(16))) + } + if (state.pin.isNotEmpty()) Text(stringResource(R.string.devgroup_pin, state.pin)) + if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error) + for (entry in state.invitations) { + val label = when (entry.state) { + 1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring + 4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired + else -> R.string.devgroup_unknown + } + Text(entry.peer + " — " + stringResource(label)) + if (!entry.outgoing && entry.state == 1) TextButton(onClick = { invite = entry.id; pin = "" }) { + Text(stringResource(R.string.devgroup_enter_pin)) + } + } + if (state.member) TextButton(onClick = { DevgroupRepository.command(4) }) { Text(stringResource(R.string.devgroup_leave)) } + } }, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } }) + if (invite.isNotEmpty()) AlertDialog(onDismissRequest = { invite = "" }, title = { Text(stringResource(R.string.devgroup_enter_pin)) }, + text = { OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin = it }, + label = { Text(stringResource(R.string.devgroup_pin_hint)) }) }, + confirmButton = { TextButton(enabled = pin.length == 4, onClick = { + DevgroupRepository.command(2, text = invite, pin = pin); invite = ""; pin = "" + }) { Text(stringResource(android.R.string.ok)) } }) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index df4d77131..bb1a2530b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import com.utun.chat.data.DevgroupRepository import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -67,11 +69,26 @@ fun MemberListScreen( ) { var showEditDialog by remember(channel.id) { mutableStateOf(false) } var editMember by remember(channel.id) { mutableStateOf(null) } + var devicesDialog by remember { mutableStateOf(false) } + var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf()) } + val devices by DevgroupRepository.state.collectAsState() + val rows = remember(members, expandedGroups) { + buildList> { + val seen = mutableSetOf() + for (member in members) { + if (member.devgroup.isEmpty()) add(member to false) + else if (seen.add(member.devgroup)) { + add(member to true) + if (member.devgroup in expandedGroups) members.filter { it.devgroup == member.devgroup }.forEach { add(it to false) } + } + } + } + } val selection = selectedMember?.takeIf { it.channelId == channel.id } LaunchedEffect(channel.id) { LogManager.addLog("DEBUG", "Members", "Member list: channel=${channel.id} actionWidth=36dp statusIconGap=2dp") - viewModel.refreshMembers(channel.id) + while (true) { viewModel.refreshMembers(channel.id); kotlinx.coroutines.delay(2000) } } val memberLinks by viewModel.memberLinks.collectAsState() @@ -98,13 +115,24 @@ fun MemberListScreen( } ) + TextButton(onClick = { devicesDialog = true }) { + Text(stringResource(R.string.devgroup_title) + if (devices.invitations.any { !it.outgoing && it.state == 1 }) " •" else "") + } LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth() ) { - items(members, key = { it.nodeId }) { m -> - MemberListItem( + items(rows, key = { if (it.second) "group:" + it.first.devgroup else "node:" + it.first.nodeId }) { (m, header) -> + if (header) { + val online = members.any { it.devgroup == m.devgroup && it.online && !it.isDeleted } + Text((if (m.devgroup in expandedGroups) "[−] " else "[+] ") + m.devgroupName, + color = if (online) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth().clickable { + expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup + }.padding(16.dp)) + } else MemberListItem( member = m, group = channel.id, speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null @@ -150,6 +178,9 @@ fun MemberListScreen( } } + if (devicesDialog) DevgroupDialog(channel.id, selection?.nodeId?.takeIf { id -> members.any { it.nodeId == id && !it.isSelf } } ?: 0) { + devicesDialog = false + } if (radioMuteFailed) { AlertDialog( onDismissRequest = viewModel::dismissRadioMuteError, diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index a35586bcb..d00511208 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -673,4 +673,21 @@ КиБ байт/с КиБ/с + Мои устройства + Нет группы устройств + Имя группы устройств + Создать / переименовать + Добавить устройство %1$s + PIN: %1$s — введите его на другом устройстве в течение 2 минут. + Операция не выполнена. Проверьте соединение и состояние приглашения. + Ожидается PIN + Проверка PIN + Передача ключей + Устройство добавлено + Отказ + Истёк срок + Результат неизвестен + Ввести PIN + PIN с экрана приглашающего устройства + Выйти из группы устройств diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 501da1ade..358b8b38a 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -672,4 +672,21 @@ KiB B/s KiB/s + My devices + No device group + Device group name + Create / rename + Add device %1$s + PIN: %1$s — enter it on the other device within 2 minutes. + Operation failed. Check connection and invitation status. + Waiting for PIN + Checking PIN + Transferring keys + Joined + Rejected + Expired + Result unknown + Enter PIN + PIN shown on the inviting device + Leave device group diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRepositoryTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRepositoryTest.kt index 875921de8..5b55f6f5e 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRepositoryTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MemberRepositoryTest.kt @@ -6,6 +6,16 @@ import java.nio.ByteBuffer import java.nio.ByteOrder class MemberRepositoryTest { + @org.junit.Test fun deviceGroupSurvivesTheSharedMemberSnapshot() { + val bytes = ByteArray(MEMBER_WIRE_SIZE) + java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(7) + bytes[164] = 42 + "Мои устройства".toByteArray(Charsets.UTF_8).copyInto(bytes, 196) + val member = requireNotNull(parseMemberWire(bytes)).toChatMember() + org.junit.Assert.assertEquals("2a" + "00".repeat(31), member.devgroup) + org.junit.Assert.assertEquals("Мои устройства", member.devgroupName) + org.junit.Assert.assertEquals(7L, member.nodeId) + } private fun row(node: Long, flags: Int = 0x40, online: Boolean = true, up: Int = 5, rtt: Int = 30): ByteArray = ByteArray(MEMBER_WIRE_SIZE).also { bytes -> ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(0, node).putShort(11, rtt.toShort()) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 9533d397c..38a4ef8e7 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1,3 +1,4 @@ +#include "chat/chat_devgroup.h" /* * android_jni_bridge.c — JNI bridge implementation * @@ -2283,6 +2284,25 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( } /* UTF-8 JSON приходит байтами, чтобы emoji не превращались в JNI modified UTF-8. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDevgroupCommand( + JNIEnv* env, jobject thiz, jint action, jlong group, jlong peer, jbyteArray text, jbyteArray pin, jlong request_id) { + (void)thiz; + if (!text || !pin || !instance_lite_is_running()) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: core unavailable/invalid request"); return JNI_FALSE; + } + jsize n = (*env)->GetArrayLength(env, text), m = (*env)->GetArrayLength(env, pin); + if (n >= 128 || m >= 5) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: input too long"); return JNI_FALSE; } + struct chat_devgroup_request* req = u_calloc(1, sizeof(*req)); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup JNI: allocation failed"); return JNI_FALSE; } + req->action = action; req->group = (uint64_t)group; req->peer = (uint64_t)peer; req->request_id = (uint64_t)request_id; + (*env)->GetByteArrayRegion(env, text, 0, n, (jbyte*)req->text); + (*env)->GetByteArrayRegion(env, pin, 0, m, (jbyte*)req->pin); + if ((*env)->ExceptionCheck(env) || memchr(req->text, 0, n) || memchr(req->pin, 0, m)) { + memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: invalid text"); return JNI_FALSE; + } + return bridge_post_request(chat_devgroup_trampoline, req, &req->inst) == 0 ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFeedbackCommand( JNIEnv* env, jobject thiz, jbyteArray json, jlong request_id) { (void)thiz; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 2863bac62..7f8ad4bd5 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -324,6 +324,7 @@ add_executable(vibechat WIN32 src/attachment_key_ui.cpp src/messagefeedback.cpp src/accountlist.cpp + src/devgroupdialog.cpp src/accountdelegate.cpp src/flagpainter.cpp src/memberpropsdialog.cpp @@ -845,7 +846,7 @@ target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute) set(RADIO_MEMBER_TEST_SOURCES src/memberlistmodel.cpp src/flagpainter.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp src/avatars/avatarcrop.cpp src/avatars/avatargallery.cpp src/imageviewer_window.cpp db/db_manager.cpp) -add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/accountdelegate.cpp +add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/devgroupdialog.cpp src/accountdelegate.cpp src/memberpropsdialog.cpp src/renamedialog.cpp ${RADIO_MEMBER_TEST_SOURCES}) target_include_directories(test_member_detail PRIVATE ${UTUN_INCLUDE_DIRS}) target_link_libraries(test_member_detail PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread) diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index 422111146..11e61c649 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -86,6 +86,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio else painter->fillRect(option.rect, option.palette.alternateBase()); + if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) { + QFont font = painter->font(); font.setBold(true); painter->setFont(font); + painter->setPen(index.data(MemberListModel::OnlineRole).toBool() ? QColor("#4CAF50") : option.palette.color(QPalette::Text)); + painter->drawText(option.rect.adjusted(8, 0, -8, 0), Qt::AlignVCenter, index.data().toString()); + painter->restore(); return; + } const int margin = 4; const int iconSz = MemberListModel::kAvatarSize; @@ -154,6 +160,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) { Q_UNUSED(model); + if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false; if (event->type() == QEvent::MouseMove) { const auto* mouse = static_cast(event); if (m_pressedIndex.isValid() && (!(mouse->buttons() & Qt::LeftButton) || @@ -192,6 +199,7 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, const QModelIndex& index) { + if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false; const bool self = index.data(Qt::UserRole + 2).toBool(); if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) { const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED; diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index a22fb9960..f5f443085 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -1,3 +1,5 @@ +#include "devgroupdialog.h" +#include #include #include "accountlist.h" #include "addressprofiletext.h" @@ -82,33 +84,37 @@ AccountList::AccountList(DbManager* db, QWidget *parent) m_header->setAlignment(Qt::AlignCenter); m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); layout->addWidget(m_header); + auto* devices = new QPushButton(tr("My devices"), this); layout->addWidget(devices); + watchDevgroupButton(devices); + connect(devices, &QPushButton::clicked, this, [this, devices] { devices->setText(tr("My devices")); showDevgroupDialog(this, m_channelId); }); - m_listView->setModel(m_model); + m_grouped = new GroupedMemberModel(m_model, this); + m_listView->setModel(m_grouped); auto* delegate = new AccountDelegate(m_listView); m_listView->setItemDelegate(delegate); connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) { - if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0) + if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(m_grouped->sourceRow(index.row()))) < 0) QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"), QCoreApplication::translate("ChatGui", "Cannot change radio listening setting")); }); connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) { - showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row())); + showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->sourceRow(index.row()))); }); connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) { - const quint64 peer = m_model->nodeIdAt(index.row()); - if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) { + const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); + if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d", - (unsigned long long)peer, m_model->isOnlineAt(index.row())); return; + (unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return; } DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx", qPrintable(m_channelId), (unsigned long long)peer); emit radioCallRequested(m_channelId, peer); }); connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) { - const quint64 peer = m_model->nodeIdAt(index.row()); - if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) { + const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); + if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d", - (unsigned long long)peer, m_model->isOnlineAt(index.row())); return; + (unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return; } DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx", qPrintable(m_channelId), (unsigned long long)peer); @@ -218,7 +224,8 @@ void AccountList::refresh() { m_model->refresh(); } void AccountList::onMemberClicked(const QModelIndex& index) { if (!index.isValid()) return; - quint64 nodeId = m_model->nodeIdAt(index.row()); + if (m_grouped->toggle(index.row())) { clearMemberDetail(); return; } + quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); if (nodeId) showMemberDetail(nodeId); } @@ -251,8 +258,8 @@ void AccountList::onSnapshotChanged(quint64 nodeId) { } if (!m_model->member(m_detailNodeId)) { clearMemberDetail(); return; } m_detailDirty = true; m_detailHops.clear(); - for (int row = 0; row < m_model->rowCount(); row++) if (m_model->nodeIdAt(row) == m_detailNodeId) { - m_listView->setCurrentIndex(m_model->index(row)); break; + for (int row = 0; row < m_grouped->rowCount(); row++) if (m_model->nodeIdAt(m_grouped->sourceRow(row)) == m_detailNodeId) { + m_listView->setCurrentIndex(m_grouped->index(row)); break; } updateDetailDisplay(); requestMemberDetail(); @@ -514,14 +521,14 @@ void AccountList::editMember(quint64 nodeId, bool properties) { } void AccountList::onMemberDoubleClicked(const QModelIndex& index) { - if (index.isValid()) editMember(m_model->nodeIdAt(index.row()), true); + if (index.isValid()) editMember(m_model->nodeIdAt(m_grouped->sourceRow(index.row())), true); } /* Вложенный event loop меню допускает смену канала, снимка и уничтожение панели. */ void AccountList::onMemberContextMenu(const QPoint& pos) { const QModelIndex index = m_listView->indexAt(pos); if (!index.isValid()) return; - const quint64 nodeId = m_model->nodeIdAt(index.row()); + const quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); const auto member = m_model->member(nodeId); if (!nodeId || !member) return; const QString channel = m_channelId; @@ -530,6 +537,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { QPointer alive(this); QMenu menu; connect(this, &QObject::destroyed, &menu, &QMenu::close); + QAction* deviceAction = menu.addAction(tr("Add to my devices")); deviceAction->setEnabled(!self); QAction* pmAction = menu.addAction(QCoreApplication::translate("ChatGui", "Send message")); pmAction->setEnabled(!self); QAction* inviteAction = menu.addAction(QCoreApplication::translate("ChatGui", "Invite to group")); @@ -567,6 +575,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { if (chosen == filesAction) { emit filesRequested(channel, nodeId); return; } if (chosen == pmAction) { emit pmRequested(channel, nodeId, name); return; } if (chosen == callAction) { emit callRequested(channel, nodeId, name); return; } + if (chosen == deviceAction) { showDevgroupDialog(this, channel, nodeId); return; } if (chosen == connectAction) { auto* request = static_cast(u_calloc(1, sizeof(chat_connect_node_req))); if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; } diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h index a96f2c393..6dc9bdd67 100644 --- a/tools/chatgui/src/accountlist.h +++ b/tools/chatgui/src/accountlist.h @@ -21,6 +21,7 @@ #include class MemberListModel; +class GroupedMemberModel; class DbManager; struct DetailAddress { @@ -101,6 +102,7 @@ private: QListView *m_listView; MemberListModel *m_model; + GroupedMemberModel* m_grouped; QPointer m_db; // не владеет ни объектом, ни SQLite; доступ к handle сериализует внешний lifecycle QLabel *m_header; QWidget *m_detailPanel; diff --git a/tools/chatgui/src/devgroupdialog.cpp b/tools/chatgui/src/devgroupdialog.cpp new file mode 100644 index 000000000..d6a1c5ba4 --- /dev/null +++ b/tools/chatgui/src/devgroupdialog.cpp @@ -0,0 +1,116 @@ +#include +#include +#include "devgroupdialog.h" +#include "../transport/gui_bridge.h" +#include "debug_ui.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "../../../src/chat/chat_devgroup.h" +#include "../../../lib/mem.h" +} +class DevgroupDialog; +static QPointer active; +static QList> buttons; +static quint64 nextRequest = 0; +static void devgroupResult(const uint8_t* data, int len); +class DevgroupDialog : public QDialog { +public: + static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); } + void refresh() { send(CHAT_DEVGROUP_STATUS); } + DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) { + setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(tr("My devices")); resize(480, 360); + auto* layout = new QVBoxLayout(this); + title = new QLabel(this); layout->addWidget(title); + name = new QLineEdit(this); name->setPlaceholderText(tr("Device group name")); layout->addWidget(name); + auto* rename = new QPushButton(tr("Create / rename"), this); layout->addWidget(rename); + connect(rename, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_RENAME, name->text()); }); + if (peer) { + auto* invite = new QPushButton(tr("Add device %1").arg(QString::number(peer, 16)), this); layout->addWidget(invite); + connect(invite, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_INVITE); }); + } + status = new QLabel(this); status->setWordWrap(true); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status); + list = new QListWidget(this); layout->addWidget(list); + auto* accept = new QPushButton(tr("Enter PIN for selected invitation"), this); layout->addWidget(accept); + connect(accept, &QPushButton::clicked, this, [this] { + const auto* item = list->currentItem(); if (!item || item->data(Qt::UserRole).toString().isEmpty()) return; + const QString id = item->data(Qt::UserRole).toString(); bool ok = false; + const QString pin = QInputDialog::getText(this, tr("Add device"), tr("PIN shown on the inviting device"), QLineEdit::Normal, {}, &ok); + if (ok) send(CHAT_DEVGROUP_ACCEPT, id, pin); + }); + auto* leave = new QPushButton(tr("Leave device group"), this); layout->addWidget(leave); + connect(leave, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_LEAVE); }); + auto* timer = new QTimer(this); timer->setInterval(1000); + connect(timer, &QTimer::timeout, this, [this] { if (!gui_bridge_is_uasync_ready()) pending.clear(); + else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) send(CHAT_DEVGROUP_STATUS); }); + timer->start(); send(CHAT_DEVGROUP_STATUS); + } + void result(const uint8_t* data, int len) { + if (len < 9) return; + quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i); + if (!pending.contains(id)) return; + const int action = pending.take(id); + if (!data[8]) { status->setText(tr("Operation failed. Check connection and invitation status.")); return; } + const auto json = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data+9), len-9)).object(); + if (action == CHAT_DEVGROUP_INVITE) { status->setText(tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString())); return; } + title->setText(json["group"].toString().isEmpty() ? tr("No device group") : json["name"].toString()); + const QString selected = list->currentItem() ? list->currentItem()->data(Qt::UserRole).toString() : QString(); + list->clear(); + const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"), + tr("Rejected"), tr("Expired"), tr("Result unknown")}; + for (const auto value : json["invitations"].toArray()) { + const auto invitation = value.toObject(); const int state = invitation["state"].toInt(); + auto* item = new QListWidgetItem(invitation["peer"].toString() + " — " + states.value(state), list); + if (!invitation["outgoing"].toInt() && state == 1) { + item->setData(Qt::UserRole, invitation["id"].toString()); + if (item->data(Qt::UserRole).toString() == selected) list->setCurrentItem(item); + } + } + } +private: + void send(chat_devgroup_action action, const QString& text = {}, const QString& pin = {}) { + const auto bytes = text.toUtf8(), code = pin.toUtf8(); + if (bytes.size() >= 128 || bytes.contains('\0') || code.size() >= 5 || code.contains('\0')) { + status->setText(tr("Invalid name or PIN")); return; + } + auto* req = static_cast(u_calloc(1, sizeof(chat_devgroup_request))); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; } + req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer; + req->action = action; memcpy(req->text, bytes.constData(), bytes.size()); memcpy(req->pin, code.constData(), code.size()); + const quint64 id = req->request_id; + if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) { + memset(req, 0, sizeof(*req)); u_free(req); status->setText(tr("Core unavailable")); + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return; + } + pending.insert(id, action); + } + QString channel; quint64 peer; + QLabel *title, *status; QLineEdit* name; QListWidget* list; + QHash pending; +}; +static void devgroupResult(const uint8_t* data, int len) { + if (!len) { + for (const auto& button : buttons) if (button) button->setText(QCoreApplication::translate("AccountList", "My devices") + " •"); + if (active) active->refresh(); + } else if (active) active->result(data, len); +} +void watchDevgroupButton(QPushButton* button) { + buttons.erase(std::remove_if(buttons.begin(), buttons.end(), [](const QPointer& b) { return b.isNull(); }), buttons.end()); + buttons.append(button); gui_bridge_set_devgroup_result_cb(devgroupResult); +} +void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) { + if (active) { active->close(); active = nullptr; } + gui_bridge_set_devgroup_result_cb(devgroupResult); + active = new DevgroupDialog(parent, channel, peer); active->show(); +} diff --git a/tools/chatgui/src/devgroupdialog.h b/tools/chatgui/src/devgroupdialog.h new file mode 100644 index 000000000..9b8c5cbbc --- /dev/null +++ b/tools/chatgui/src/devgroupdialog.h @@ -0,0 +1,11 @@ +/* Локальное управление devgroup. Все операции передаются в uasync; секреты не читаются GUI. */ +#ifndef DEVGROUPDIALOG_H +#define DEVGROUPDIALOG_H +#include +#include +class QWidget; +class QPushButton; +/* Помечать кнопку при изменении приглашений; указатель отслеживается через QPointer. */ +void watchDevgroupButton(QPushButton* button); +void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer = 0); +#endif diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index cc4583d20..78d828fcc 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -318,3 +318,56 @@ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) { return; } } + +static QByteArray memberGroup(const MemberListModel* source, int row) { + const auto member = source->member(source->nodeIdAt(row)); + if (!member || !member->devgroup_name[0]) return {}; + return QByteArray(reinterpret_cast(member->devgroup), 32); +} +GroupedMemberModel::GroupedMemberModel(MemberListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) { + connect(source, &QAbstractItemModel::modelReset, this, [this] { rebuild(true); }); + connect(source, &QAbstractItemModel::rowsInserted, this, [this] { rebuild(); }); + connect(source, &QAbstractItemModel::rowsRemoved, this, [this] { rebuild(); }); + connect(source, &QAbstractItemModel::dataChanged, this, [this] { rebuild(); }); + rebuild(); +} +void GroupedMemberModel::rebuild(bool forceReset) { + QVector rows; QSet seen; + for (int i = 0; i < m_source->rowCount(); ++i) { + const auto group = memberGroup(m_source, i); + if (group.isEmpty()) { rows.append(i); continue; } + if (seen.contains(group)) continue; + seen.insert(group); rows.append(-i-1); + if (m_expanded.contains(group)) for (int j = 0; j < m_source->rowCount(); ++j) + if (memberGroup(m_source, j) == group) rows.append(j); + } + if (forceReset || rows != m_rows) { beginResetModel(); m_rows = rows; endResetModel(); } + else if (!rows.isEmpty()) emit dataChanged(index(0), index(rows.size()-1)); +} +int GroupedMemberModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : m_rows.size(); } +int GroupedMemberModel::sourceRow(int row) const { return row >= 0 && row < m_rows.size() ? m_rows[row] : -1; } +bool GroupedMemberModel::toggle(int row) { + if (row < 0 || row >= m_rows.size() || m_rows[row] >= 0) return false; + const auto group = memberGroup(m_source, -m_rows[row]-1); + if (!m_expanded.remove(group)) m_expanded.insert(group); + rebuild(); return true; +} +QVariant GroupedMemberModel::data(const QModelIndex& idx, int role) const { + if (!idx.isValid() || idx.model() != this || idx.row() < 0 || idx.row() >= m_rows.size()) return {}; + const int row = m_rows[idx.row()]; + if (role == GroupHeaderRole) return row < 0; + if (row >= 0) { + const auto value = m_source->data(m_source->index(row), role); + return role == Qt::DisplayRole && !memberGroup(m_source, row).isEmpty() ? QVariant(" " + value.toString()) : value; + } + const auto member = m_source->member(m_source->nodeIdAt(-row-1)); if (!member) return {}; + const auto group = memberGroup(m_source, -row-1); + if (role == Qt::DisplayRole) return (m_expanded.contains(group) ? QStringLiteral("[−] ") : QStringLiteral("[+] ")) + + QString::fromUtf8(member->devgroup_name); + if (role == MemberListModel::OnlineRole) { + for (int i = 0; i < m_source->rowCount(); ++i) + if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i)) return true; + return false; + } + return {}; +} diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index ed7c6a703..f3a972974 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -78,4 +78,20 @@ private: QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели }; +/* Представление поверх снимков: заголовки не имеют node_id, действия доступны только детям. */ +class GroupedMemberModel : public QAbstractListModel { + Q_OBJECT +public: + static constexpr int GroupHeaderRole = Qt::UserRole + 7; + explicit GroupedMemberModel(MemberListModel* source, QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + int sourceRow(int row) const; // -1 для заголовка/неверной строки + bool toggle(int row); // true — обработан заголовок +private: + void rebuild(bool forceReset = false); + MemberListModel* m_source; + QVector m_rows; // >=0 — строка источника, <0 — заголовок группы строки -value-1 + QSet m_expanded; +}; #endif diff --git a/tools/chatgui/tests/test_member_detail.cpp b/tools/chatgui/tests/test_member_detail.cpp index aa82beaba..7e093a5c4 100644 --- a/tools/chatgui/tests/test_member_detail.cpp +++ b/tools/chatgui/tests/test_member_detail.cpp @@ -156,6 +156,28 @@ private slots: QCOMPARE(body(panel).section('\n', 0, 0), QString("DIR(NONE-STRICT) RTT=5ms")); QCOMPARE(requests.size(), previousRequests); } + void devgroupHeadersAreExpandableAndNeverRepresentADevice() { + MemberListModel source; source.setChannel("123", false); + GroupedMemberModel grouped(&source); + QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest); + auto first = row(7, true), second = row(8, false), other = row(9, false); + for (auto* bytes : {&first, &second}) { + memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); + } + auto data = first + second + other; + source.onMemberList("123", 3, reinterpret_cast(data.constData()), data.size()); + QCOMPARE(grouped.rowCount(), 2); QCOMPARE(grouped.sourceRow(0), -1); + QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool()); + QVERIFY(grouped.index(0).data(MemberListModel::OnlineRole).toBool()); + QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4); + QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7)); + QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8)); + source.onMemberRemoved("123", 7); + QVERIFY(!grouped.index(0).data(MemberListModel::OnlineRole).toBool()); + QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(8)); + QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2); + } + void flatModelPreservesRowsSnapshotsAndPersistentIndexes() { MemberListModel model; QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); @@ -427,3 +449,5 @@ private slots: QTEST_MAIN(MemberDetailTest) #include "test_member_detail.moc" + +void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn) {} diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 1e18ae9b6..3aa4b90d0 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2858,4 +2858,32 @@ See log: PNG preserves the original pixels and transparency. + + DevgroupDialog + My devicesMy devices + Device group nameDevice group name + Create / renameCreate / rename + Add device %1Add device %1 + Enter PIN for selected invitationEnter PIN for selected invitation + Add deviceAdd device + PIN shown on the inviting devicePIN shown on the inviting device + Leave device groupLeave device group + Operation failed. Check connection and invitation status.Operation failed. Check connection and invitation status. + PIN: %1 — enter it on the other device within 2 minutes.PIN: %1 — enter it on the other device within 2 minutes. + No device groupNo device group + Waiting for PINWaiting for PIN + Checking PINChecking PIN + Transferring keysTransferring keys + JoinedJoined + RejectedRejected + ExpiredExpired + Result unknownResult unknown + Invalid name or PINInvalid name or PIN + Core unavailableCore unavailable + + + AccountList + My devicesMy devices + Add to my devicesAdd to my devices + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index f886ccb5c..a2ec0acf1 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2868,4 +2868,32 @@ See log: PNG сохраняет исходные пиксели и прозрачность. + + DevgroupDialog + My devicesМои устройства + Device group nameИмя группы устройств + Create / renameСоздать / переименовать + Add device %1Добавить устройство %1 + Enter PIN for selected invitationВвести PIN выбранного приглашения + Add deviceДобавление устройства + PIN shown on the inviting devicePIN с экрана приглашающего устройства + Leave device groupВыйти из группы устройств + Operation failed. Check connection and invitation status.Операция не выполнена. Проверьте соединение и состояние приглашения. + PIN: %1 — enter it on the other device within 2 minutes.PIN: %1 — введите его на другом устройстве в течение 2 минут. + No device groupНет группы устройств + Waiting for PINОжидается PIN + Checking PINПроверка PIN + Transferring keysПередача ключей + JoinedУстройство добавлено + RejectedОтказ + ExpiredИстёк срок + Result unknownРезультат неизвестен + Invalid name or PINНеверное имя или PIN + Core unavailableЯдро недоступно + + + AccountList + My devicesМои устройства + Add to my devicesДобавить в мои устройства + diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 93050deff..96814ed07 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -341,6 +341,7 @@ void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb); void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb); /* Изменение локальных запросов/ключей вложения, без секретных данных. */ void gui_bridge_set_attachment_keys_cb(gui_dm_messages_fn cb); +void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb); void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb); void gui_bridge_set_msg_submitted_cb(gui_dm_messages_fn cb); void gui_bridge_set_msg_updated_cb(gui_dm_messages_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 7a18ff77c..6ae1d68e7 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -95,6 +95,7 @@ static gui_dm_messages_fn g_avatar_result_cb = nullptr; static gui_dm_messages_fn g_group_avatar_result_cb = nullptr; static gui_dm_messages_fn g_feedback_changed_cb = nullptr; static gui_dm_messages_fn g_attachment_keys_cb = nullptr; +static gui_dm_messages_fn g_devgroup_result_cb = nullptr; static gui_dm_messages_fn g_feedback_result_cb = nullptr; static gui_dm_messages_fn g_msg_submitted_cb = nullptr; static gui_dm_messages_fn g_msg_updated_cb = nullptr; @@ -351,6 +352,12 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { case CHAT_EVT_ATTACHMENT_KEYS_CHANGED: if (g_attachment_keys_cb) g_attachment_keys_cb(d, dlen); break; + case CHAT_EVT_DEVGROUP_CHANGED: + if (g_devgroup_result_cb) g_devgroup_result_cb(nullptr, 0); + break; + case CHAT_EVT_DEVGROUP_RESULT: + if (g_devgroup_result_cb) g_devgroup_result_cb(d, dlen); + break; case CHAT_EVT_FEEDBACK_RESULT: if (g_feedback_result_cb) g_feedback_result_cb(d, dlen); break; @@ -1362,3 +1369,5 @@ extern "C" void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP #include "gui_bridge_impl.moc" void gui_bridge_set_call_stats_cb(gui_call_stats_fn cb) { g_call_stats_cb = cb; } + +void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb) { g_devgroup_result_cb = cb; }