Add signed device groups and compact NODEINFO packing
This commit is contained in:
1 parent
ef34f81c90
commit
d7006aecb5
52 files changed
+1979
-94
No files matched your search
+57
-48
@@ -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_<channel_id> и 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 и секреты.
|
||||
@@ -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.
|
||||
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;не отдельное соединение БД. */
|
||||
|
||||
@@ -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 <openssl/crypto.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <openssl/rand.h>
|
||||
#include <openssl/crypto.h>
|
||||
|
||||
#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; i<n; i++) snprintf(out + 2*i, 3,"%02x", in[i]); }
|
||||
static int dg_id(const char* text, uint8_t out[16]) {
|
||||
return !text || strlen(text) != 32 || strspn(text,"0123456789abcdef") != 32 || sc_hex_to_binary(text, out, 16) ? -1 : 0;
|
||||
}
|
||||
static int dg_peer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t x[32], uint8_t ed[32]) {
|
||||
char channel[32]; snprintf(channel, sizeof(channel),"%llu", (unsigned long long)group);
|
||||
struct TOPO_NODE* node = topo_node_registry_find(inst->topo_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; i<DG_SLOTS; i++) if (ctx->slots[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; i<DG_SLOTS; i++) {
|
||||
struct dg_invite* p = &ctx->slots[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->len<ROUTER_SVC_HDR_SIZE + 17 || (entry->dgram[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; i<DG_SLOTS; i++) {
|
||||
struct dg_invite* p = &ctx->slots[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->sent<DG_RETRY) continue;
|
||||
if (p->outgoing && 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; i<DG_SLOTS; i++) {
|
||||
struct dg_invite* p = &ctx->slots[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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
// ── Подключение получателя событий ──
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include "chat_devgroup.h"
|
||||
#include <openssl/crypto.h>
|
||||
/*
|
||||
* 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; }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <sqlite3.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <limits.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* devgroup — общая подписанная принадлежность устройства и профиль группы.
|
||||
* Публичная запись хранится по значению в TOPO_NODE; нулевая запись означает отсутствие группы.
|
||||
* JSON пересылается без пересериализации, ключи группы независимы от ключей устройства.
|
||||
* Все операции БД/instance вызываются в uasync-потоке; codec не обращается к instance. */
|
||||
#ifndef UTUN_DEVGROUP_H
|
||||
#define UTUN_DEVGROUP_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
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
|
||||
@@ -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]) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "etcp.h"
|
||||
#include "config_parser.h"
|
||||
#include "topo_node.h"
|
||||
#include <openssl/crypto.h>
|
||||
#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;
|
||||
|
||||
@@ -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-сведения
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <openssl/evp.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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 <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <ch_id>"),))
|
||||
|
||||
@@ -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 <ch_id> <node_hex> пригласить известное устройство; ответ содержит PIN
|
||||
devgroup accept <invite_id> ввод 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 результат неизвестен.
|
||||
Остановка сервиса отменяет ожидания; сохранённые ключи и квитанции импорта переживают перезапуск.
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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<ConnInfo> {
|
||||
}
|
||||
|
||||
/* Размер общего снимка из 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,
|
||||
|
||||
@@ -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<DeviceInvitation> = 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<Long, Int>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)) } })
|
||||
}
|
||||
+34
-3
@@ -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<ChatMember?>(null) }
|
||||
var devicesDialog by remember { mutableStateOf(false) }
|
||||
var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf<String>()) }
|
||||
val devices by DevgroupRepository.state.collectAsState()
|
||||
val rows = remember(members, expandedGroups) {
|
||||
buildList<Pair<ChatMember, Boolean>> {
|
||||
val seen = mutableSetOf<String>()
|
||||
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,
|
||||
|
||||
@@ -673,4 +673,21 @@
|
||||
<string name="audio_graph_kib">КиБ</string>
|
||||
<string name="audio_graph_bps">байт/с</string>
|
||||
<string name="audio_graph_kibps">КиБ/с</string>
|
||||
<string name="devgroup_title">Мои устройства</string>
|
||||
<string name="devgroup_none">Нет группы устройств</string>
|
||||
<string name="devgroup_name">Имя группы устройств</string>
|
||||
<string name="devgroup_rename">Создать / переименовать</string>
|
||||
<string name="devgroup_add">Добавить устройство %1$s</string>
|
||||
<string name="devgroup_pin">PIN: %1$s — введите его на другом устройстве в течение 2 минут.</string>
|
||||
<string name="devgroup_failed">Операция не выполнена. Проверьте соединение и состояние приглашения.</string>
|
||||
<string name="devgroup_waiting">Ожидается PIN</string>
|
||||
<string name="devgroup_checking">Проверка PIN</string>
|
||||
<string name="devgroup_transferring">Передача ключей</string>
|
||||
<string name="devgroup_joined">Устройство добавлено</string>
|
||||
<string name="devgroup_rejected">Отказ</string>
|
||||
<string name="devgroup_expired">Истёк срок</string>
|
||||
<string name="devgroup_unknown">Результат неизвестен</string>
|
||||
<string name="devgroup_enter_pin">Ввести PIN</string>
|
||||
<string name="devgroup_pin_hint">PIN с экрана приглашающего устройства</string>
|
||||
<string name="devgroup_leave">Выйти из группы устройств</string>
|
||||
</resources>
|
||||
@@ -672,4 +672,21 @@
|
||||
<string name="audio_graph_kib">KiB</string>
|
||||
<string name="audio_graph_bps">B/s</string>
|
||||
<string name="audio_graph_kibps">KiB/s</string>
|
||||
<string name="devgroup_title">My devices</string>
|
||||
<string name="devgroup_none">No device group</string>
|
||||
<string name="devgroup_name">Device group name</string>
|
||||
<string name="devgroup_rename">Create / rename</string>
|
||||
<string name="devgroup_add">Add device %1$s</string>
|
||||
<string name="devgroup_pin">PIN: %1$s — enter it on the other device within 2 minutes.</string>
|
||||
<string name="devgroup_failed">Operation failed. Check connection and invitation status.</string>
|
||||
<string name="devgroup_waiting">Waiting for PIN</string>
|
||||
<string name="devgroup_checking">Checking PIN</string>
|
||||
<string name="devgroup_transferring">Transferring keys</string>
|
||||
<string name="devgroup_joined">Joined</string>
|
||||
<string name="devgroup_rejected">Rejected</string>
|
||||
<string name="devgroup_expired">Expired</string>
|
||||
<string name="devgroup_unknown">Result unknown</string>
|
||||
<string name="devgroup_enter_pin">Enter PIN</string>
|
||||
<string name="devgroup_pin_hint">PIN shown on the inviting device</string>
|
||||
<string name="devgroup_leave">Leave device group</string>
|
||||
</resources>
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<QMouseEvent*>(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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include "devgroupdialog.h"
|
||||
#include <QPushButton>
|
||||
#include <QCoreApplication>
|
||||
#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<AccountList> 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<chat_connect_node_req*>(u_calloc(1, sizeof(chat_connect_node_req)));
|
||||
if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; }
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <stdint.h>
|
||||
|
||||
class MemberListModel;
|
||||
class GroupedMemberModel;
|
||||
class DbManager;
|
||||
|
||||
struct DetailAddress {
|
||||
@@ -101,6 +102,7 @@ private:
|
||||
|
||||
QListView *m_listView;
|
||||
MemberListModel *m_model;
|
||||
GroupedMemberModel* m_grouped;
|
||||
QPointer<DbManager> m_db; // не владеет ни объектом, ни SQLite; доступ к handle сериализует внешний lifecycle
|
||||
QLabel *m_header;
|
||||
QWidget *m_detailPanel;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#include <QCoreApplication>
|
||||
#include <algorithm>
|
||||
#include "devgroupdialog.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "debug_ui.h"
|
||||
#include <QDialog>
|
||||
#include <QVBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QInputDialog>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QTimer>
|
||||
#include <QPointer>
|
||||
#include <QHash>
|
||||
extern "C" {
|
||||
#include "../../../src/chat/chat_devgroup.h"
|
||||
#include "../../../lib/mem.h"
|
||||
}
|
||||
class DevgroupDialog;
|
||||
static QPointer<DevgroupDialog> active;
|
||||
static QList<QPointer<QPushButton>> 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<const char*>(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<chat_devgroup_request*>(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<quint64, int> 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<QPushButton>& 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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Локальное управление devgroup. Все операции передаются в uasync; секреты не читаются GUI. */
|
||||
#ifndef DEVGROUPDIALOG_H
|
||||
#define DEVGROUPDIALOG_H
|
||||
#include <QtGlobal>
|
||||
#include <QString>
|
||||
class QWidget;
|
||||
class QPushButton;
|
||||
/* Помечать кнопку при изменении приглашений; указатель отслеживается через QPointer. */
|
||||
void watchDevgroupButton(QPushButton* button);
|
||||
void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer = 0);
|
||||
#endif
|
||||
@@ -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<const char*>(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<int> rows; QSet<QByteArray> 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 {};
|
||||
}
|
||||
@@ -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<int> m_rows; // >=0 — строка источника, <0 — заголовок группы строки -value-1
|
||||
QSet<QByteArray> m_expanded;
|
||||
};
|
||||
#endif
|
||||
@@ -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<const uint8_t*>(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) {}
|
||||
@@ -2858,4 +2858,32 @@ See log:
|
||||
<translation>PNG preserves the original pixels and transparency.</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>DevgroupDialog</name>
|
||||
<message><source>My devices</source><translation>My devices</translation></message>
|
||||
<message><source>Device group name</source><translation>Device group name</translation></message>
|
||||
<message><source>Create / rename</source><translation>Create / rename</translation></message>
|
||||
<message><source>Add device %1</source><translation>Add device %1</translation></message>
|
||||
<message><source>Enter PIN for selected invitation</source><translation>Enter PIN for selected invitation</translation></message>
|
||||
<message><source>Add device</source><translation>Add device</translation></message>
|
||||
<message><source>PIN shown on the inviting device</source><translation>PIN shown on the inviting device</translation></message>
|
||||
<message><source>Leave device group</source><translation>Leave device group</translation></message>
|
||||
<message><source>Operation failed. Check connection and invitation status.</source><translation>Operation failed. Check connection and invitation status.</translation></message>
|
||||
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — enter it on the other device within 2 minutes.</translation></message>
|
||||
<message><source>No device group</source><translation>No device group</translation></message>
|
||||
<message><source>Waiting for PIN</source><translation>Waiting for PIN</translation></message>
|
||||
<message><source>Checking PIN</source><translation>Checking PIN</translation></message>
|
||||
<message><source>Transferring keys</source><translation>Transferring keys</translation></message>
|
||||
<message><source>Joined</source><translation>Joined</translation></message>
|
||||
<message><source>Rejected</source><translation>Rejected</translation></message>
|
||||
<message><source>Expired</source><translation>Expired</translation></message>
|
||||
<message><source>Result unknown</source><translation>Result unknown</translation></message>
|
||||
<message><source>Invalid name or PIN</source><translation>Invalid name or PIN</translation></message>
|
||||
<message><source>Core unavailable</source><translation>Core unavailable</translation></message>
|
||||
</context>
|
||||
<context>
|
||||
<name>AccountList</name>
|
||||
<message><source>My devices</source><translation>My devices</translation></message>
|
||||
<message><source>Add to my devices</source><translation>Add to my devices</translation></message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -2868,4 +2868,32 @@ See log:
|
||||
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>DevgroupDialog</name>
|
||||
<message><source>My devices</source><translation>Мои устройства</translation></message>
|
||||
<message><source>Device group name</source><translation>Имя группы устройств</translation></message>
|
||||
<message><source>Create / rename</source><translation>Создать / переименовать</translation></message>
|
||||
<message><source>Add device %1</source><translation>Добавить устройство %1</translation></message>
|
||||
<message><source>Enter PIN for selected invitation</source><translation>Ввести PIN выбранного приглашения</translation></message>
|
||||
<message><source>Add device</source><translation>Добавление устройства</translation></message>
|
||||
<message><source>PIN shown on the inviting device</source><translation>PIN с экрана приглашающего устройства</translation></message>
|
||||
<message><source>Leave device group</source><translation>Выйти из группы устройств</translation></message>
|
||||
<message><source>Operation failed. Check connection and invitation status.</source><translation>Операция не выполнена. Проверьте соединение и состояние приглашения.</translation></message>
|
||||
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — введите его на другом устройстве в течение 2 минут.</translation></message>
|
||||
<message><source>No device group</source><translation>Нет группы устройств</translation></message>
|
||||
<message><source>Waiting for PIN</source><translation>Ожидается PIN</translation></message>
|
||||
<message><source>Checking PIN</source><translation>Проверка PIN</translation></message>
|
||||
<message><source>Transferring keys</source><translation>Передача ключей</translation></message>
|
||||
<message><source>Joined</source><translation>Устройство добавлено</translation></message>
|
||||
<message><source>Rejected</source><translation>Отказ</translation></message>
|
||||
<message><source>Expired</source><translation>Истёк срок</translation></message>
|
||||
<message><source>Result unknown</source><translation>Результат неизвестен</translation></message>
|
||||
<message><source>Invalid name or PIN</source><translation>Неверное имя или PIN</translation></message>
|
||||
<message><source>Core unavailable</source><translation>Ядро недоступно</translation></message>
|
||||
</context>
|
||||
<context>
|
||||
<name>AccountList</name>
|
||||
<message><source>My devices</source><translation>Мои устройства</translation></message>
|
||||
<message><source>Add to my devices</source><translation>Добавить в мои устройства</translation></message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user