60 changed files with 2532 additions and 170 deletions

No files matched your search

+59 -48
View File
@@ -1,48 +1,59 @@
Внедрение группы устройств в чат (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: приглашение отправляется B, на A показывается PIN из 4 цифр.
На B автоматически появляется диалог ввода PIN с экрана A; введённый PIN возвращается A для проверки.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
Только после успешной проверки PIN A создаёт обе пары ключей, если группы ещё нет; иначе использует существующие.
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. Выход.
Кнопка «Отключить от моих устройств» доступна в свойствах self. Выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком 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 и секреты.
+29
View File
@@ -60,6 +60,11 @@ NCD пропускает нулевые порты. Непубличный ло
snapshot загрузка использует его. Локальная публикация сохраняется до рассылки;
ошибка сохранения не разрешает публикацию новой версии.
`node_snapshots.format=1` отмечает текущий формат NODEINFO. При обновлении старой
БД несовместимые BLOB очищаются, timestamp и адреса сохраняются для bootstrap.
Пустые снимки не участвуют в классификации пиров; новый проверенный снимок может
заполнить такой кеш с тем же timestamp. Повторный запуск сохраняет новые снимки.
В проекции адресов сохраняются `nat_mapping`, `nat_inbound`, `nat_source`, `address_kind`.
Уникальность определяется endpoint и видом адреса,
а не отображаемым классом NAT. Старый производный формат пересоздаётся;
@@ -167,3 +172,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.
+10
View File
@@ -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 \
+8
View File
@@ -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",
+2
View File
@@ -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;не отдельное соединение БД. */
+203
View File
@@ -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);
}
+49
View File
@@ -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
+288
View File
@@ -0,0 +1,288 @@
#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);
const uint8_t empty[32] = {0};
int pinned = memcmp(p->group_ed, empty, 32) != 0;
if (!has && !pinned) {
if (chat_devgroup_rename(ctx->inst, ctx->inst->name[0] ? ctx->inst->name : "My devices")) has = -1;
else has = devgroup_load_local(ctx->inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
}
if (has != 1 || (pinned && 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;
}
memcpy(p->group_ed, own.ed, 32);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: PIN verified, transferring existing group keys peer=%016llx",
(unsigned long long)peer);
p->state = 3; OPENSSL_cleanse(p->pin, sizeof(p->pin)); OPENSSL_cleanse(p->last_pin, sizeof(p->last_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 < 0) return -1;
if (has) memcpy(p->group_ed, own.ed, 32); // Нулевая привязка: группа создаётся после проверки PIN.
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;
}
+2 -2
View File
@@ -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);
}
+2
View File
@@ -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. */
// ── Подключение получателя событий ──
+28
View File
@@ -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; }
}
+9 -1
View File
@@ -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:
{
+9 -5
View File
@@ -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);
+293
View File
@@ -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;
}
+57
View File
@@ -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
+1 -1
View File
@@ -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]) {
+42 -6
View File
@@ -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;
+8 -4
View File
@@ -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-сведения
+37 -6
View File
@@ -264,6 +264,32 @@ int topo_node_sqlite_get_member_channels(sqlite3* db, uint64_t node_id,
return 0;
}
/* Формат BLOB меняется вместе с NODEINFO. Старые байты не декодируем: адресная проекция
* обеспечивает bootstrap, timestamp сохраняет нижнюю границу новой подписи владельца. */
static int snapshot_schema(sqlite3* db) {
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM pragma_table_info('node_snapshots') WHERE name='format'", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int present = rc == SQLITE_ROW && sqlite3_column_int(st, 0);
int final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format inspection failed rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(db)); return -1;
}
if (present) return 0;
const char* migration = "SAVEPOINT snapshot_format;"
"ALTER TABLE node_snapshots ADD COLUMN format INTEGER NOT NULL DEFAULT 0;"
"UPDATE node_snapshots SET record=X'';"
"RELEASE snapshot_format;";
if (sqlite3_exec(db, migration, NULL, NULL, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format migration failed: %s", sqlite3_errmsg(db));
if (sqlite3_exec(db, "ROLLBACK TO snapshot_format; RELEASE snapshot_format", NULL, NULL, NULL) != SQLITE_OK)
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format migration rollback failed: %s", sqlite3_errmsg(db));
return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_BGP, "NODEINFO cache upgraded: obsolete blobs invalidated; timestamps, addresses, members and keys preserved");
return 0;
}
int topo_node_sqlite_init(sqlite3* db) {
if (!db) return -1;
@@ -330,7 +356,7 @@ int topo_node_sqlite_init(sqlite3* db) {
const char* sql =
"CREATE TABLE IF NOT EXISTS node_snapshots ("
"node_id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, record BLOB NOT NULL);"
"node_id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, record BLOB NOT NULL, format INTEGER NOT NULL DEFAULT 1);"
"CREATE TABLE IF NOT EXISTS nodes ("
" node_id INTEGER PRIMARY KEY,"
" name TEXT,"
@@ -392,6 +418,8 @@ int topo_node_sqlite_init(sqlite3* db) {
return -1;
}
if (snapshot_schema(db) || devgroup_schema(db)) return -1;
/* Старые подписи непригодны; адреса и максимальные версии остаются для bootstrap и новой публикации. */
if (legacy_profile) {
const char *migration = "SAVEPOINT compact_nat_profile;"
@@ -1311,15 +1339,18 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) {
int rc = sqlite3_exec(db, "SAVEPOINT node_snapshot", NULL, NULL, NULL);
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot begin rc=%d: %s", rc, sqlite3_errmsg(db)); return -1; }
sqlite3_stmt* stmt = NULL;
const char* sql = "INSERT INTO node_snapshots(node_id,timestamp,record) VALUES(?,?,?) "
"ON CONFLICT(node_id) DO UPDATE SET timestamp=excluded.timestamp,record=excluded.record "
"WHERE excluded.timestamp > node_snapshots.timestamp";
const char* sql = "INSERT INTO node_snapshots(node_id,timestamp,record,format) VALUES(?,?,?,1) "
"ON CONFLICT(node_id) DO UPDATE SET timestamp=excluded.timestamp,record=excluded.record,format=excluded.format "
"WHERE excluded.timestamp > node_snapshots.timestamp OR "
"(excluded.timestamp = node_snapshots.timestamp AND length(node_snapshots.record)=0)";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 1, (sqlite3_int64)ni->node_id);
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;
@@ -1341,7 +1372,7 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) {
struct TOPO_NODE* topo_node_sqlite_snapshot_load(sqlite3* db, struct TOPO_GROUPS* groups, uint64_t node_id) {
sqlite3_stmt* stmt = NULL;
struct TOPO_NODE* ni = NULL;
if (sqlite3_prepare_v2(db, "SELECT record FROM node_snapshots WHERE node_id=?", -1, &stmt, NULL) != SQLITE_OK) {
if (sqlite3_prepare_v2(db, "SELECT record FROM node_snapshots WHERE node_id=? AND format=1", -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot load: %s", sqlite3_errmsg(db)); return NULL;
}
sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id);
@@ -1603,7 +1634,7 @@ int topo_node_sqlite_get_connect_peers(sqlite3* db, const char* channel_id,
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT p.node_id,p.adm_tags,s.timestamp,s.record FROM \"%s\" p"
" LEFT JOIN node_snapshots s ON s.node_id=p.node_id WHERE p.deleted=0 AND p.source=0"
" LEFT JOIN node_snapshots s ON s.node_id=p.node_id AND s.format=1 AND length(s.record)>0 WHERE p.deleted=0 AND p.source=0"
" ORDER BY p.connected DESC,p.node_RTT IS NULL,p.node_RTT,p.node_id", table);
sqlite3_stmt* stmt = NULL;
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
+1
View File
@@ -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
View File
@@ -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);
+9
View File
@@ -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
+8
View File
@@ -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));
+73
View File
@@ -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;
}
+217
View File
@@ -0,0 +1,217 @@
/* Настоящий 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];
struct devgroup_record before; uint8_t before_ed[32], before_x[32];
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(devgroup_load_local(nodes[0], &before, before_ed, before_x) == 0);
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(devgroup_load_local(nodes[0], &before, before_ed, before_x) == 0);
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(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_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;
}
+28
View File
@@ -140,6 +140,33 @@ static void test_verified_write(void) {
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] verified node writes: insert/update, rejected writes, prepare failure and stored state");
}
static void test_snapshot_format_upgrade(struct UTUN_INSTANCE* inst) {
sqlite3* saved = inst->topo_sqlite_db;
sqlite3* db = NULL; assert(sqlite3_open(":memory:", &db) == SQLITE_OK);
inst->topo_sqlite_db = db;
assert(sqlite3_exec(db, "CREATE TABLE node_snapshots(node_id INTEGER PRIMARY KEY,timestamp INTEGER NOT NULL,record BLOB NOT NULL);"
"INSERT INTO node_snapshots VALUES(7,9000000,X'0102030405')", NULL, NULL, NULL) == SQLITE_OK);
assert(!topo_node_sqlite_init(db));
uint64_t timestamp = 0;
assert(!topo_node_sqlite_snapshot_timestamp(db, 7, &timestamp) && timestamp == 9000000);
assert(!topo_node_sqlite_channel_put(db, "123", "upgrade", 7, inst->my_keys.public_key, NULL, inst->my_ed25519_pubkey, NULL,
(const uint8_t[64]){0}));
assert(!topo_node_sqlite_member_placeholder_put(db, "123", 7, inst->my_keys.public_key, inst->my_ed25519_pubkey));
assert(sqlite3_exec(db, "UPDATE peers_123 SET source=0", NULL, NULL, NULL) == SQLITE_OK);
struct topo_connect_peer* peers = NULL; size_t count = 0;
assert(!topo_node_sqlite_get_connect_peers(db, "123", &peers, &count) && count == 1 && peers[0].client_type == UINT8_MAX);
u_free(peers);
struct TOPO_NODE* self = record(inst, 123, "format fixture", 1); assert(self);
assert(!topo_node_sqlite_snapshot_put(db, self));
assert(sqlite3_exec(db, "UPDATE node_snapshots SET record=X'' WHERE format=1", NULL, NULL, NULL) == SQLITE_OK);
assert(!topo_node_sqlite_snapshot_put(db, self)); // Та же версия снова заполняет инвалидированный кеш.
assert(!topo_node_sqlite_init(db));
struct TOPO_NODE* loaded = topo_node_sqlite_snapshot_load(db, inst->topo_groups, inst->node_id);
assert(loaded && loaded->timestamp == self->timestamp); topo_node_destroy(inst->topo_groups, loaded);
topo_node_destroy(inst->topo_groups, self); sqlite3_close(db); inst->topo_sqlite_db = saved;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] NODEINFO format upgrade: old blobs ignored, candidate scan works, timestamps and new snapshots survive restart");
}
/* Новый wire и SQL сохраняют адреса, протоколы, классификацию и REALITY; смена формата оставляет bootstrap. */
static void test_compact_addresses(struct UTUN_INSTANCE *inst) {
test_zero_port_schema();
@@ -358,6 +385,7 @@ int main(void) {
assert(sqlite3_open(":memory:", &inst.topo_sqlite_db) == SQLITE_OK);
assert(topo_node_sqlite_init(inst.topo_sqlite_db) == 0);
test_verified_write();
test_snapshot_format_upgrade(&inst);
test_compact_addresses(&inst);
test_local_group_update(&inst);
+29 -2
View File
@@ -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, &copy, 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);
+13
View File
@@ -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>"),))
+11
View File
@@ -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 == 12 || 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) }
}
@@ -639,6 +639,7 @@ class MainActivity : AppCompatActivity() {
dismissButton = { TextButton(onClick = CallController::cancelOutgoing) { Text(stringResource(R.string.cancel)) } }
)
}
com.utun.chat.ui.screens.DevicePairingDialogs()
val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
localInvitations.firstOrNull()?.let { invitation ->
AlertDialog(
@@ -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,54 @@
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, val outgoingVisible: Boolean = false,
val leaving: Boolean = false, val leaveFailed: Boolean = false,
val outgoingId: String = "", val accepting: String = "", val dismissed: Set<String> = emptySet())
/** Только 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
if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false)
if (action == 2) mutable.value = mutable.value.copy(accepting = text, failed = false)
if (action == 4) mutable.value = mutable.value.copy(leaving = true, leaveFailed = false)
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, accepting = "", leaving = false, leaveFailed = action == 4) }
}
fun dismissLeaveError() { mutable.value = mutable.value.copy(leaveFailed = false) }
fun closeOutgoing() { mutable.value = mutable.value.copy(outgoingVisible = false, outgoingId = "", pin = "", failed = false) }
fun dismissIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed + id, failed = false) }
fun onEvent(type: Int, data: ByteArray?) {
if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return }
if (type == 12) { command(); 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, accepting = "", leaving = false, leaveFailed = action == 4); return }
val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))
if (action == 1) {
if (mutable.value.outgoingVisible) mutable.value = mutable.value.copy(pin = json.getString("pin"), outgoingId = json.getString("id"), 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")) },
leaving = if (action == 4) false else mutable.value.leaving,
accepting = if (action == 2) "" else mutable.value.accepting,
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,74 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
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
private fun deviceInvitationState(state: Int): Int = when (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
}
/** Диалоги живут на уровне Activity, входящее приглашение видно с любого экрана. */
@Composable
fun DevicePairingDialogs() {
val state by DevgroupRepository.state.collectAsState()
var current by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") }
var pin by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf("") }
var submitted by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf(false) }
LaunchedEffect(state.invitations, state.dismissed, current) {
if (current.isNotEmpty() && state.invitations.none { it.id == current }) current = ""
if (current.isEmpty()) current = state.invitations.firstOrNull {
!it.outgoing && it.state == 1 && it.id !in state.dismissed
}?.id ?: ""
}
LaunchedEffect(state.outgoingVisible, current) {
if (state.outgoingVisible || current.isNotEmpty()) while (true) { DevgroupRepository.command(); delay(1000) }
}
if (state.leaveFailed) {
AlertDialog(onDismissRequest = DevgroupRepository::dismissLeaveError,
title = { Text(stringResource(R.string.devgroup_disconnect)) },
text = { Text(stringResource(R.string.devgroup_failed)) },
confirmButton = { TextButton(onClick = DevgroupRepository::dismissLeaveError) { Text(stringResource(android.R.string.ok)) } })
return
}
if (state.outgoingVisible) {
val invitation = state.invitations.firstOrNull { it.id == state.outgoingId }
AlertDialog(onDismissRequest = DevgroupRepository::closeOutgoing,
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.pin.isNotEmpty() && (invitation?.state ?: 1) < 4) Text(stringResource(R.string.devgroup_pin, state.pin))
Text(stringResource(deviceInvitationState(invitation?.state ?: 1)))
if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error)
} }, confirmButton = { TextButton(onClick = DevgroupRepository::closeOutgoing) { Text(stringResource(R.string.close)) } })
return
}
val invitation = state.invitations.firstOrNull { it.id == current } ?: return
val busy = invitation.state == 2 || state.accepting == current
val finished = invitation.state >= 4
val close = { DevgroupRepository.dismissIncoming(current); current = ""; Unit }
AlertDialog(onDismissRequest = close,
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(invitation.peer)
if (finished || busy) Text(stringResource(deviceInvitationState(invitation.state)))
else {
OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all { c -> c in '0'..'9' }) pin = it },
label = { Text(stringResource(R.string.devgroup_pin_hint)) }, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.NumberPassword))
if (submitted || state.failed) Text(stringResource(R.string.devgroup_pin_retry), color = MaterialTheme.colorScheme.error)
}
} }, confirmButton = {
if (finished) TextButton(onClick = close) { Text(stringResource(android.R.string.ok)) }
else TextButton(enabled = !busy && pin.length == 4, onClick = {
submitted = true; DevgroupRepository.command(2, text = current, pin = pin); pin = ""
}) { Text(stringResource(android.R.string.ok)) }
}, dismissButton = { TextButton(onClick = close) { Text(stringResource(R.string.close)) } })
}
@@ -22,6 +22,7 @@ import com.utun.chat.data.LogManager
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Bolt
@@ -29,6 +30,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 +70,25 @@ fun MemberListScreen(
) {
var showEditDialog by remember(channel.id) { mutableStateOf(false) }
var editMember by remember(channel.id) { mutableStateOf<ChatMember?>(null) }
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()
@@ -103,17 +120,37 @@ fun MemberListScreen(
.weight(1f)
.fillMaxWidth()
) {
items(members, key = { it.nodeId }) { m ->
items(rows, key = { if (it.second) "group:" + it.first.devgroup else "node:" + it.first.nodeId }) { (m, header) ->
val accounts = if (header) members.filter { it.devgroup == m.devgroup && !it.isDeleted } else listOf(m)
val target = if (header) accounts.firstOrNull { it.online } else m
val device = target ?: m
val groupRadio = accounts.any { it.radioActive }
val groupPhoneKnown = accounts.any { it.phoneStatusKnown }
val groupPhoneBusy = accounts.any { it.phoneBusy }
LaunchedEffect(header, target?.nodeId, groupRadio, groupPhoneKnown, groupPhoneBusy) {
if (header) LogManager.addLog("DEBUG", "Members", "Device group: group=${m.devgroup} " +
"target=${target?.nodeId} radio=$groupRadio phoneKnown=$groupPhoneKnown phoneBusy=$groupPhoneBusy speakerColumn=right")
}
MemberListItem(
member = m,
member = device,
avatarMember = m,
groupName = if (header) m.devgroupName else null,
expanded = m.devgroup in expandedGroups,
actionsEnabled = target != null,
radioEnabled = groupRadio,
phoneStatusKnown = groupPhoneKnown,
phoneBusy = groupPhoneBusy,
onToggleGroup = {
expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup
},
group = channel.id,
speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null
else radioTalkState.groupId == groupId && m.nodeId in radioTalkState.speakers,
isSelected = selection?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onRadioCall = { com.utun.chat.data.RadioCallController.start(channel.id, m.nodeId) },
onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) }
speaking = target != null && radioActive && if (device.isSelf) transmittingGroup == groupId && groupId != null
else radioTalkState.groupId == groupId && device.nodeId in radioTalkState.speakers,
isSelected = !header && selection?.nodeId == device.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, device.nodeId) },
onCall = { target?.let { viewModel.startCall(channel.id, it.nodeId, it.name) } },
onRadioCall = { target?.let { com.utun.chat.data.RadioCallController.start(channel.id, it.nodeId) } },
onRadioMute = { target?.let { viewModel.toggleMemberRadioMute(channel.id, it.nodeId) } }
)
}
}
@@ -132,6 +169,9 @@ fun MemberListScreen(
isSelf = member?.isSelf == true,
canEdit = member != null,
onFiles = { onFiles(detail.nodeId) },
deviceActionEnabled = member != null && !member.isDeleted && !devices.leaving && (!member.isSelf || devices.member),
onDeviceAction = { if (member?.isSelf == true) DevgroupRepository.command(4)
else DevgroupRepository.command(1, channel.id.toULong().toLong(), detail.nodeId) },
onInvite = { viewModel.showPmInvite(detail.nodeId, member?.name ?: detail.name, channel.id) },
onMessage = { viewModel.startDm(channel.id, detail.nodeId, member?.name ?: detail.name) },
onConnect = {
@@ -176,6 +216,9 @@ fun MemberListScreen(
viewModel.setMemberFlags(channel.id, editingMember.nodeId, admin, supernode, moder, storage, verified, deleted)
showEditDialog = false
},
canDisconnect = devices.member && !devices.leaving,
onDeviceAction = { if (editingMember.isSelf) DevgroupRepository.command(4)
else DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) },
onTransferAdmin = {
viewModel.transferAdmin(channel.id, editingMember.nodeId)
showEditDialog = false
@@ -188,6 +231,14 @@ fun MemberListScreen(
@Composable
private fun MemberListItem(
member: ChatMember,
avatarMember: ChatMember = member,
groupName: String? = null,
expanded: Boolean = false,
actionsEnabled: Boolean = true,
radioEnabled: Boolean = member.radioActive,
phoneStatusKnown: Boolean = member.phoneStatusKnown,
phoneBusy: Boolean = member.phoneBusy,
onToggleGroup: () -> Unit = {},
group: String,
speaking: Boolean,
isSelected: Boolean = false,
@@ -196,6 +247,7 @@ private fun MemberListItem(
onRadioCall: () -> Unit,
onRadioMute: () -> Unit
) {
val header = groupName != null
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay)
@@ -206,7 +258,7 @@ private fun MemberListItem(
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
.clickable(onClick = if (header) onToggleGroup else onClick),
color = bgColor,
tonalElevation = 0.dp
) {
@@ -214,9 +266,15 @@ private fun MemberListItem(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
if (header) IconButton(onClick = onToggleGroup, modifier = Modifier.size(width = 24.dp, height = 48.dp)) {
Icon(if (expanded) Icons.Filled.Remove else Icons.Filled.Add,
stringResource(if (expanded) R.string.devgroup_collapse else R.string.devgroup_expand), modifier = Modifier.size(18.dp))
} else Spacer(Modifier.width(24.dp))
}
Box {
UserAvatar(group, member.nodeId, member.name)
if (member.isSelf) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
UserAvatar(group, avatarMember.nodeId, avatarMember.name)
if (member.isSelf && !header) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
fontSize = 12.sp, color = Color(0xFF81C784))
else Box(Modifier.align(Alignment.BottomEnd).size(10.dp).clip(CircleShape).background(onlineColor))
}
@@ -224,33 +282,33 @@ private fun MemberListItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
if (!header && member.isSuper) {
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isAdmin) {
if (!header && member.isAdmin) {
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isModer) {
if (!header && member.isModer) {
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isStorage) {
if (!header && member.isStorage) {
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (!member.isVerified) {
if (!header && !member.isVerified) {
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
Text(
text = member.name,
fontWeight = FontWeight.Medium,
text = groupName ?: member.name,
fontWeight = if (header) FontWeight.Bold else FontWeight.Medium,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
textDecoration = if (!header && member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (member.isSelf) Color(0xFF81C784) else Color.Unspecified,
modifier = Modifier.weight(1f)
)
@@ -264,7 +322,7 @@ private fun MemberListItem(
else -> stringResource(R.string.offline)
}
Text(
text = connectionLabel, fontSize = 11.sp,
text = if (header && actionsEnabled) member.name + " · " + connectionLabel else connectionLabel, fontSize = 11.sp,
color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
@@ -273,27 +331,29 @@ private fun MemberListItem(
if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else ""
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
val actionModifier = Modifier.size(width = 36.dp, height = 48.dp)
if (if (header) actionsEnabled else !member.isSelf) {
IconButton(onClick = onRadioCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (radioEnabled) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (radioEnabled) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val status = if (!member.online || !phoneStatusKnown) R.string.phone_status_unknown
else if (phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !phoneStatusKnown) LocalContentColor.current
else if (phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
if (member.isSelf) {
Box(actionModifier, contentAlignment = Alignment.Center) {
RadioSpeakerIcon(speaking, false, radioStatus)
}
} else {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
IconToggleButton(checked = member.radioMuted, enabled = actionsEnabled, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " +
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute))
}
IconButton(onClick = onRadioCall, enabled = member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (member.radioActive) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (member.radioActive) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) {
val status = if (!member.online || !member.phoneStatusKnown) R.string.phone_status_unknown
else if (member.phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !member.phoneStatusKnown) LocalContentColor.current
else if (member.phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
}
}
@@ -314,6 +374,8 @@ private fun MemberDetailCard(
localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false,
canEdit: Boolean,
deviceActionEnabled: Boolean,
onDeviceAction: () -> Unit,
onFiles: () -> Unit,
onInvite: () -> Unit,
onMessage: () -> Unit,
@@ -410,6 +472,9 @@ private fun MemberDetailCard(
}
}
TextButton(onClick = onDeviceAction, enabled = deviceActionEnabled) {
Text(stringResource(if (isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device))
}
if (!isSelf) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onMessage) {
@@ -549,6 +614,8 @@ private fun MemberEditDialog(
onDismiss: () -> Unit,
onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) -> Unit,
canDisconnect: Boolean,
onDeviceAction: () -> Unit,
onTransferAdmin: () -> Unit
) {
var nick by remember { mutableStateOf(member.name) }
@@ -574,6 +641,9 @@ private fun MemberEditDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && (!member.isSelf || canDisconnect)) {
Text(stringResource(if (member.isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device))
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = admin, onCheckedChange = { admin = it }, enabled = isOwner)
@@ -673,4 +673,19 @@
<string name="audio_graph_kib">КиБ</string>
<string name="audio_graph_bps">байт/с</string>
<string name="audio_graph_kibps">КиБ/с</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_pin_hint">PIN с экрана приглашающего устройства</string>
<string name="devgroup_add_device">Добавить устройство</string>
<string name="devgroup_pin_retry">PIN не принят. Попробуйте ещё раз.</string>
<string name="devgroup_disconnect">Отключить от моих устройств</string>
<string name="devgroup_expand">Развернуть устройства</string>
<string name="devgroup_collapse">Свернуть устройства</string>
</resources>
@@ -672,4 +672,19 @@
<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_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_pin_hint">PIN shown on the inviting device</string>
<string name="devgroup_add_device">Add device</string>
<string name="devgroup_pin_retry">PIN was not accepted. Try again.</string>
<string name="devgroup_disconnect">Disconnect from my devices</string>
<string name="devgroup_expand">Expand devices</string>
<string name="devgroup_collapse">Collapse devices</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;
+3 -2
View File
@@ -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
@@ -608,7 +609,7 @@ endif()
add_test(NAME test_call_commands COMMAND test_call_commands)
set_tests_properties(test_call_commands PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30)
add_executable(test_member_dialogs tests/test_member_dialogs.cpp src/renamedialog.cpp src/memberpropsdialog.cpp
add_executable(test_member_dialogs tests/test_member_dialogs.cpp src/renamedialog.cpp src/memberpropsdialog.cpp src/devgroupdialog.cpp
db/db_manager.cpp transport/gui_bridge_impl.cpp src/connmonitorwindow.cpp)
target_link_libraries(test_member_dialogs PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun utun_voice pthread ${CMAKE_DL_LIBS})
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
@@ -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)
+50 -18
View File
@@ -16,7 +16,7 @@ extern "C" {
}
QRect AccountDelegate::actionRect(const QRect& row, Action action) {
const int offset = action == Action::RadioMute ? 95 : action == Action::RadioCall ? 63 : 31;
const int offset = action == Action::RadioMute ? 31 : action == Action::RadioCall ? 95 : 63;
return QRect(row.right() - offset, row.center().y() - 14, 28, 28);
}
@@ -59,12 +59,13 @@ static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool mut
static QRect avatarRect(const QRect& row) {
const int size = MemberListModel::kAvatarSize;
return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size);
return QRect(row.left() + GroupedMemberModel::DisclosureWidth + 4, row.top() + (row.height() - size) / 2, size, size);
}
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool self) {
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool showCalls) {
if (avatarRect(row).contains(position)) return Action::Avatar;
if (!self) for (Action action : {Action::RadioMute, Action::RadioCall, Action::Call})
if (actionRect(row, Action::RadioMute).contains(position)) return Action::RadioMute;
if (showCalls) for (Action action : {Action::RadioCall, Action::Call})
if (actionRect(row, action).contains(position)) return action;
return Action::None;
}
@@ -86,16 +87,30 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
else
painter->fillRect(option.rect, option.palette.alternateBase());
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
if (header) {
const QRectF box(option.rect.left() + 5, option.rect.center().y() - 7, 14, 14);
painter->setRenderHint(QPainter::Antialiasing); painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(option.palette.color(QPalette::Text), 1.3)); painter->drawRoundedRect(box, 2, 2);
painter->drawLine(QPointF(box.left() + 3, box.center().y()), QPointF(box.right() - 3, box.center().y()));
if (!index.data(GroupedMemberModel::GroupExpandedRole).toBool())
painter->drawLine(QPointF(box.center().x(), box.top() + 3), QPointF(box.center().x(), box.bottom() - 3));
}
const int margin = 4;
const int iconSz = MemberListModel::kAvatarSize;
QIcon icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole));
if (!icon.isNull())
icon.paint(painter, avatarRect(option.rect));
if (header) {
const QRect avatar = avatarRect(option.rect); const QColor color(index.data(MemberListModel::OnlineRole).toBool() ? "#4CAF50" : "#9E9E9E");
painter->setPen(QPen(color, 1)); painter->setBrush(color); painter->drawEllipse(QRect(avatar.right() - 8, avatar.bottom() - 8, 8, 8));
}
const int x = option.rect.left() + iconSz + margin * 3;
const int x = option.rect.left() + GroupedMemberModel::DisclosureWidth + iconSz + margin * 3;
const bool self = index.data(Qt::UserRole + 2).toBool();
const int w = qMax(0, option.rect.right() - x - margin - (self ? 32 : 96));
const bool online = index.data(MemberListModel::OnlineRole).toBool(), showCalls = header ? online : !self;
const int w = qMax(0, option.rect.right() - x - margin - (showCalls ? 96 : 32));
const int yMid = option.rect.top() + option.rect.height() / 2;
uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF);
@@ -103,7 +118,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QFont nameFont = painter->font();
nameFont.setBold(true);
nameFont.setPixelSize(13);
if (flags & MEMBER_FLAG_DELETED) nameFont.setStrikeOut(true);
if (!header && (flags & MEMBER_FLAG_DELETED)) nameFont.setStrikeOut(true);
painter->setFont(nameFont);
QColor textColor = option.palette.color(QPalette::Text);
@@ -113,7 +128,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->save();
painter->setClipRect(QRect(x, option.rect.top(), w, option.rect.height()), Qt::IntersectClip);
const uint8_t visualFlags = flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
const uint8_t visualFlags = header ? 0 : flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
int flagX = x;
if (visualFlags) {
FlagPainter::draw(painter, flags, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize());
@@ -136,10 +151,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
Qt::AlignLeft | Qt::AlignTop, sub);
painter->restore();
drawRadioMuteButton(painter, actionRect(option.rect, self ? Action::Call : Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
const bool available = !header || index.data(MemberListModel::OnlineRole).toBool();
painter->save(); if (!available) painter->setOpacity(0.35);
drawRadioMuteButton(painter, actionRect(option.rect, Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
index.data(MemberListModel::RadioSpeakingRole).toBool());
if (!self) {
const QColor color = index.data(MemberListModel::OnlineRole).toBool() ? textColor :
if (showCalls) {
const QColor color = online ? option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text) :
option.palette.color(QPalette::Disabled, QPalette::Text);
const QRect radio = actionRect(option.rect, Action::RadioCall);
const QColor radioColor = flags & CHAT_MEMBER_FLAG_RADIO ? QColor("#4CAF50") : color;
@@ -149,6 +166,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
drawCallButton(painter, actionRect(option.rect, Action::Call), phoneColor);
}
painter->restore();
painter->restore();
}
bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
@@ -168,43 +186,57 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
event->type() != QEvent::MouseButtonDblClick) return false;
auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false;
const Action action = actionAt(mouse->pos(), option.rect, index.data(Qt::UserRole + 2).toBool());
const bool enabled = action == Action::Avatar || action == Action::RadioMute || index.data(MemberListModel::OnlineRole).toBool();
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
const bool self = index.data(Qt::UserRole + 2).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
Action action = actionAt(mouse->pos(), option.rect, header ? online : !self);
if (header && action == Action::None) action = Action::ToggleGroup;
const bool enabled = action == Action::Avatar || action == Action::ToggleGroup ||
(!self && (!header || online) && (action == Action::RadioMute || online));
if (event->type() == QEvent::MouseButtonPress) {
m_pressedIndex = action != Action::None && enabled ? index : QModelIndex();
m_pressedNodeId = index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong();
m_pressedAction = action; m_pressedPosition = mouse->pos();
return action != Action::None;
}
if (event->type() == QEvent::MouseButtonDblClick) return action != Action::None;
const bool handled = action != Action::None || m_pressedIndex.isValid();
const bool activate = enabled && action != Action::None && action == m_pressedAction && m_pressedIndex == index &&
m_pressedNodeId == index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong() &&
(mouse->pos() - m_pressedPosition).manhattanLength() < ClickDistance;
m_pressedIndex = QPersistentModelIndex(); m_pressedAction = Action::None;
if (activate) {
GUI_DEBUG("AccountDelegate: action activated row=%d action=%d", index.row(), int(action));
GUI_DEBUG("AccountDelegate: action activated row=%d group=%d action=%d target=%016llx online=%d flags=%02x",
index.row(), header, int(action), (unsigned long long)m_pressedNodeId, online, index.data(Qt::UserRole + 3).toInt());
if (action == Action::Avatar) emit avatarRequested(index);
else if (action == Action::RadioMute) emit radioMuteRequested(index);
else if (action == Action::RadioCall) emit radioCallRequested(index);
else if (action == Action::Call) emit callRequested(index);
else if (action == Action::ToggleGroup) emit groupToggleRequested(index);
}
return handled;
}
bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
const QModelIndex& index) {
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool() && event &&
event->pos().x() < option.rect.left() + GroupedMemberModel::DisclosureWidth) {
QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui",
index.data(GroupedMemberModel::GroupExpandedRole).toBool() ? "Collapse devices" : "Expand devices"), view); return true;
}
const bool self = index.data(Qt::UserRole + 2).toBool();
if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) {
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
if (event && actionRect(option.rect, Action::RadioMute).contains(event->pos())) {
const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED;
const bool speaking = index.data(MemberListModel::RadioSpeakingRole).toBool();
QString text = QCoreApplication::translate("ChatGui", speaking ? "Radio: speaking" : "Radio: silent");
if (muted) text += " · " + QCoreApplication::translate("ChatGui", "Radio: muted");
if (!index.data(Qt::UserRole + 2).toBool()) text += '\n' + QCoreApplication::translate("ChatGui",
if (!self && (!header || online)) text += '\n' + QCoreApplication::translate("ChatGui",
muted ? "Listen to this member in radio" : "Mute this member in radio");
QToolTip::showText(event->globalPos(), text, view);
return true;
}
if (event && !self) {
const Action action = actionAt(event->pos(), option.rect, false);
if (event && (header ? online : !self)) {
const Action action = actionAt(event->pos(), option.rect, true);
if (action == Action::RadioCall || action == Action::Call) {
QString text = QCoreApplication::translate("ChatGui", action == Action::RadioCall ? "Call by radio" : "Call peer");
if (action == Action::RadioCall && (index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO))
+8 -3
View File
@@ -3,7 +3,10 @@
* UserRole+1 — подпись, +2 — self, +3 — CHAT_MEMBER_FLAG_*, +4 — storage;
* MemberListModel::OnlineRole/RadioSpeakingRole определяют доступность и индикацию.
* Аватар доступен всем, mute — чужим участникам независимо от online, вызовы — online-пирам.
* Собственная строка показывает состояние рации без кнопок mute/вызова.
* Динамик всегда справа. Собственная строка показывает состояние рации без кнопок mute/вызова.
* Под раскрытие зарезервирована общая колонка; заголовок группы получает аватар и действия
* через GroupedMemberModel. Трубка/рация группы видны при наличии online-устройства,
* в том числе self; вызовы себе недоступны. Их состояния общие для группы.
* Сигнал только при LeftButton release в том же действии/строке после press:
* MouseMove со смещением >=5 по Manhattan или без LeftButton отменяет жест, включая возврат курсора назад. */
#pragma once
@@ -30,12 +33,14 @@ signals:
void callRequested(const QModelIndex& index);
void avatarRequested(const QModelIndex& index);
void radioMuteRequested(const QModelIndex& index);
void groupToggleRequested(const QModelIndex& index);
private:
static constexpr int ClickDistance = 5;
enum class Action { None, Avatar, RadioMute, RadioCall, Call };
enum class Action { None, Avatar, RadioMute, RadioCall, Call, ToggleGroup };
static QRect actionRect(const QRect& row, Action action);
static Action actionAt(const QPoint& position, const QRect& row, bool self);
static Action actionAt(const QPoint& position, const QRect& row, bool showCalls);
QPersistentModelIndex m_pressedIndex; /* строка, на которой начался клик */
QPoint m_pressedPosition; // координаты viewport при press
quint64 m_pressedNodeId = 0; // смена online-устройства между press/release отменяет действие
Action m_pressedAction = Action::None; // ожидаемое действие; сбрасывается при release/отмене жеста
};
+25 -16
View File
@@ -1,3 +1,5 @@
#include "devgroupdialog.h"
#include <QPushButton>
#include <QCoreApplication>
#include "accountlist.h"
#include "addressprofiletext.h"
@@ -83,36 +85,43 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);");
layout->addWidget(m_header);
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::groupToggleRequested, this, [this](const QModelIndex& index) {
if (m_grouped->toggle(index.row())) clearMemberDetail();
});
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)
const quint64 peer = m_model->nodeIdAt(m_grouped->actionSourceRow(index.row()));
if (!peer) return;
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), peer) < 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->avatarSourceRow(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 int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) {
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(source)); 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 int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) {
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(source)); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit callRequested(m_channelId, peer, index.data(Qt::DisplayRole).toString());
const auto member = m_model->member(peer);
emit callRequested(m_channelId, peer, member ? QString::fromUtf8(member->name) : QString());
});
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->setSelectionMode(QAbstractItemView::SingleSelection);
@@ -217,8 +226,8 @@ void AccountList::setChannel(const QString& channelId) {
void AccountList::refresh() { m_model->refresh(); }
void AccountList::onMemberClicked(const QModelIndex& index) {
if (!index.isValid()) return;
quint64 nodeId = m_model->nodeIdAt(index.row());
if (!index.isValid() || index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return;
quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (nodeId) showMemberDetail(nodeId);
}
@@ -251,8 +260,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 +523,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;
+2
View File
@@ -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;
+205
View File
@@ -0,0 +1,205 @@
#include <QCoreApplication>
#include "devgroupdialog.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QDialog>
#include <QVBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QLineEdit>
#include <QInputDialog>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTimer>
#include <QPointer>
#include <QHash>
#include <QMessageBox>
#include <QSet>
#include <QDialogButtonBox>
#include <QRegularExpressionValidator>
extern "C" {
#include "../../../src/chat/chat_devgroup.h"
#include "../../../lib/mem.h"
}
class DevgroupDialog;
static QPointer<DevgroupDialog> active;
static QHash<quint64, QPointer<QPushButton>> leaveRequests;
static quint64 nextRequest = 0;
static QPointer<QWidget> invitationWindow;
static quint64 statusRequest = 0, lastAutomaticSnapshot = 0;
static QHash<quint64, QString> acceptRequests;
static QHash<QString, QPointer<QInputDialog>> pinDialogs;
static QSet<QString> dismissedInvites, awaitingPins, attemptedPins;
static void refreshInvitations();
static void automaticInvitations(const QJsonObject& snapshot);
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); setModal(true); setWindowTitle(tr("Add device")); resize(420, 140);
auto* layout = new QVBoxLayout(this);
status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true);
status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status);
auto* close = new QDialogButtonBox(QDialogButtonBox::Close, this); layout->addWidget(close);
connect(close, &QDialogButtonBox::rejected, this, &QDialog::close);
auto* timer = new QTimer(this); timer->setInterval(1000);
connect(timer, &QTimer::timeout, this, [this] {
if (!gui_bridge_is_uasync_ready()) { pending.clear(); status->setText(tr("Core unavailable")); }
else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) refresh();
});
timer->start(); send(CHAT_DEVGROUP_INVITE);
}
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) {
outgoingId = json["id"].toString(); pinText = tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString());
status->setText(pinText); return;
}
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();
if (invitation["id"].toString() == outgoingId) status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state));
}
}
private:
void send(chat_devgroup_action action) {
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; const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); status->setText(tr("Core unavailable")); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return;
}
pending.insert(id, action);
}
QString channel, outgoingId, pinText; quint64 peer;
QLabel* status;
QHash<quint64, int> pending;
};
static void devgroupResult(const uint8_t* data, int len) {
if (!len) {
refreshInvitations();
if (active) active->refresh();
} else {
if (len >= 9) {
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i);
if (leaveRequests.contains(id)) {
const auto button = leaveRequests.take(id);
if (!data[8]) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect refused");
if (button) { button->setEnabled(true); QMessageBox::warning(button->window(),
QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Operation failed. Check connection and invitation status.")); }
} else DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: own device disconnected");
return;
}
const bool automatic = id == statusRequest || acceptRequests.contains(id);
if (id == statusRequest) statusRequest = 0;
if (acceptRequests.contains(id)) awaitingPins.remove(acceptRequests.take(id));
if (automatic) {
if (data[8] && id >= lastAutomaticSnapshot) {
lastAutomaticSnapshot = id;
automaticInvitations(QJsonDocument::fromJson(QByteArray(reinterpret_cast<const char*>(data+9), len-9)).object());
}
else if (!data[8]) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: automatic PIN/status request refused");
}
}
if (active) active->result(data, len);
}
}
static void refreshInvitations() {
if (!invitationWindow || statusRequest || !gui_bridge_is_uasync_ready()) return;
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: status allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_STATUS; req->request_id = ++nextRequest;
const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: status enqueue failed"); return;
}
statusRequest = id;
}
static void automaticInvitations(const QJsonObject& snapshot) {
if (!invitationWindow) return;
QSet<QString> waiting;
for (const auto value : snapshot["invitations"].toArray()) {
const auto entry = value.toObject(); const QString id = entry["id"].toString();
if (entry["outgoing"].toInt() || entry["state"].toInt() != 1) continue;
waiting.insert(id);
if (pinDialogs.value(id) || dismissedInvites.contains(id) || awaitingPins.contains(id)) continue;
auto* dialog = new QInputDialog(invitationWindow); pinDialogs.insert(id, dialog);
dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setObjectName("devgroupIncomingPin");
dialog->setWindowTitle(DevgroupDialog::tr("Add device"));
dialog->setLabelText(entry["peer"].toString() + "\n" + DevgroupDialog::tr(attemptedPins.contains(id) ?
"PIN was not accepted. Try again." : "PIN shown on the inviting device"));
dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal);
if (auto* edit = dialog->findChild<QLineEdit*>()) {
edit->setMaxLength(4); edit->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), edit));
}
QObject::connect(dialog, &QInputDialog::textValueChanged, dialog, [dialog](const QString& text) {
if (auto* box = dialog->findChild<QDialogButtonBox*>()) box->button(QDialogButtonBox::Ok)->setEnabled(text.size() == 4);
});
QObject::connect(dialog, &QDialog::finished, dialog, [dialog, id](int result) {
pinDialogs.remove(id);
if (result != QDialog::Accepted) { dismissedInvites.insert(id); return; }
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN request allocation failed"); return; }
const auto pin = dialog->textValue().toLatin1(), invite = id.toLatin1();
if (pin.size() != 4 || invite.size() != 32) { u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: invalid PIN request"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_ACCEPT; req->request_id = ++nextRequest;
memcpy(req->text, invite.constData(), 32); memcpy(req->pin, pin.constData(), 4);
const quint64 request = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN enqueue failed"); return;
}
awaitingPins.insert(id); attemptedPins.insert(id); acceptRequests.insert(request, id);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN submitted invite=%s", qPrintable(id));
});
dialog->open();
if (auto* box = dialog->findChild<QDialogButtonBox*>()) box->button(QDialogButtonBox::Ok)->setEnabled(false);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: incoming PIN dialog opened invite=%s", qPrintable(id));
}
for (const auto& id : pinDialogs.keys()) if (!waiting.contains(id)) {
auto dialog = pinDialogs.take(id); if (dialog) { dialog->disconnect(); dialog->close(); }
}
}
void initDevgroupUi(QWidget* window) {
if (invitationWindow) return;
statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); dismissedInvites.clear(); awaitingPins.clear(); attemptedPins.clear();
invitationWindow = window; gui_bridge_set_devgroup_result_cb(devgroupResult);
auto* timer = new QTimer(window); timer->setInterval(1000);
QObject::connect(timer, &QTimer::timeout, window, [] {
if (!gui_bridge_is_uasync_ready()) {
statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); awaitingPins.clear(); attemptedPins.clear(); dismissedInvites.clear();
automaticInvitations(QJsonObject{{"invitations", QJsonArray{}}}); return;
}
refreshInvitations();
});
timer->start(); refreshInvitations();
}
void inviteMyDevice(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();
}
void disconnectMyDevice(QPushButton* button) {
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_LEAVE; req->request_id = ++nextRequest;
const quint64 id = req->request_id; gui_bridge_set_devgroup_result_cb(devgroupResult);
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect enqueue failed");
QMessageBox::warning(button->window(), QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Core unavailable")); return;
}
leaveRequests.insert(id, button); button->setEnabled(false);
}
+14
View File
@@ -0,0 +1,14 @@
/* Диалоги сопряжения устройств и отключение self. Все операции передаются в uasync; секреты не читаются GUI. */
#ifndef DEVGROUPDIALOG_H
#define DEVGROUPDIALOG_H
#include <QtGlobal>
#include <QString>
class QWidget;
class QPushButton;
/* Один обработчик входящих приглашений на главное окно; диалог появляется независимо от текущего экрана. */
void initDevgroupUi(QWidget* window);
/* Сразу создать приглашение выбранному устройству и показать PIN. */
void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer);
/* Отключить self; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */
void disconnectMyDevice(QPushButton* button);
#endif
+2
View File
@@ -1,3 +1,4 @@
#include "devgroupdialog.h"
#include <QCoreApplication>
#include "mainwindow.h"
#include "single_instance.h"
@@ -172,6 +173,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
new GroupAvatarManager(m_db, this);
setupUi();
setupTray();
initDevgroupUi(this);
setupSoundFromConfig();
if (m_db) {
setupBridgeCallbacks();
+73
View File
@@ -318,3 +318,76 @@ 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);
GUI_DEBUG("Device group: toggled group=%s expanded=%d", group.toHex().constData(), m_expanded.contains(group));
rebuild(); return true;
}
int GroupedMemberModel::avatarSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
return m_rows[row] >= 0 ? m_rows[row] : -m_rows[row]-1;
}
int GroupedMemberModel::actionSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
if (m_rows[row] >= 0) return m_rows[row];
const auto group = memberGroup(m_source, -m_rows[row]-1);
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i) &&
!(m_source->index(i).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return i;
return -1;
}
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()], target = actionSourceRow(idx.row());
if (role == GroupHeaderRole) return row < 0;
if (role == ActionNodeIdRole) return QVariant::fromValue(m_source->nodeIdAt(target));
if (row >= 0) return m_source->data(m_source->index(row), role);
const int avatar = avatarSourceRow(idx.row()); const auto group = memberGroup(m_source, avatar);
const auto member = m_source->member(m_source->nodeIdAt(avatar)); if (!member) return {};
if (role == GroupExpandedRole) return m_expanded.contains(group);
if (role == Qt::DisplayRole) return QString::fromUtf8(member->devgroup_name);
if (role == Qt::DecorationRole) return m_source->data(m_source->index(avatar), role);
if (role == MemberListModel::OnlineRole) return target >= 0;
if (role == Qt::UserRole + 3) {
const int shared = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
int flags = target < 0 ? 0 : m_source->index(target).data(role).toInt() & ~shared;
for (int i = 0; i < m_source->rowCount(); ++i) if (memberGroup(m_source, i) == group) {
const int deviceFlags = m_source->index(i).data(role).toInt();
if (!(deviceFlags & MEMBER_FLAG_DELETED)) flags |= deviceFlags & shared;
}
return flags;
}
if (role == Qt::UserRole + 1) return target < 0 ? QCoreApplication::translate("ChatGui", "offline") :
m_source->data(m_source->index(target), Qt::DisplayRole).toString() + " · " + m_source->data(m_source->index(target), role).toString();
return target < 0 ? QVariant() : m_source->data(m_source->index(target), role);
}
+23
View File
@@ -78,4 +78,27 @@ private:
QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели
};
/* Представление поверх снимков: заголовки не являются устройствами; аватар берётся у первого
* участника группы, кнопки действуют на первое online-устройство. RADIO/PHONE-флаги заголовка
* объединяют состояния всех неудалённых участников; mute/self относятся к цели кнопок. Источник заимствован. */
class GroupedMemberModel : public QAbstractListModel {
Q_OBJECT
public:
static constexpr int GroupHeaderRole = Qt::UserRole + 7;
static constexpr int GroupExpandedRole = Qt::UserRole + 8;
static constexpr int ActionNodeIdRole = Qt::UserRole + 9; // конкретное устройство для кнопок; 0 если в группе нет online
static constexpr int DisclosureWidth = 24; // одинаковая левая колонка во всех строках
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 для заголовка/неверной строки
int actionSourceRow(int row) const; // заголовок: первое online-устройство, -1 если их нет
int avatarSourceRow(int row) const; // заголовок: первое устройство независимо от online
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
+12 -4
View File
@@ -1,3 +1,4 @@
#include "devgroupdialog.h"
#include <QCoreApplication>
#include "memberpropsdialog.h"
#include "../db/db_manager.h"
@@ -40,14 +41,14 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
QString curNick;
bool curVerified = false;
bool curDeleted = false;
bool curStorage = false;
bool curStorage = false, inDeviceGroup = false;
m_currentVer = 0;
bool loaded = false;
if (m_db && m_db->m_db) {
QString tbl = m_db->peersTableName(channelId);
QByteArray sql = QString(
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? WHERE p.node_id=?"
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey),length(n.devgroup_ed) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? LEFT JOIN nodes n ON n.node_id=p.node_id WHERE p.node_id=?"
).arg(tbl).toUtf8();
sqlite3_stmt* st = nullptr;
int rc = sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr);
@@ -65,7 +66,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
curDeleted = tags.value("deleted").toString() == "yes";
curStorage = tags.value("storage").toString() == "yes";
curNick = DbManager::colText(st, 1);
m_haveAdminKey = sqlite3_column_int(st, 2) == 32;
m_haveAdminKey = sqlite3_column_int(st, 2) == 32; inDeviceGroup = sqlite3_column_int(st, 3) == 32;
}
int finish = sqlite3_finalize(st); loaded = rc == SQLITE_ROW && finish == SQLITE_OK;
if (!loaded) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member properties: read ch=%s node=%016llx rc=%d finalize=%d: %s",
@@ -123,6 +124,13 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
layout->addWidget(hint);
}
auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", isOwn ? "Disconnect from my devices" : "Add device"), this);
addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !curDeleted && (!isOwn || inDeviceGroup));
connect(addDevice, &QPushButton::clicked, this, [this, addDevice] {
if (m_isOwn) disconnectMyDevice(addDevice); else inviteMyDevice(this, m_channelId, m_nodeId);
});
layout->addWidget(addDevice);
m_transferBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Transfer admin rights"), this);
m_transferBtn->setEnabled(haveKey && !isOwn);
m_transferBtn->setToolTip(QCoreApplication::translate("ChatGui", "Send the channel private key to this member (encrypted + signed)"));
+6 -5
View File
@@ -468,7 +468,7 @@ private slots:
QSignalSpy radios(&delegate, &AccountDelegate::radioCallRequested);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(row.right() - 50, row.center().y()), phone(row.right() - 17, row.center().y());
const QPoint radio(row.right() - 82, row.center().y()), phone(row.right() - 50, row.center().y());
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, phone);
QCOMPARE(radios.count(), 0); QCOMPARE(calls.count(), 0);
@@ -507,7 +507,7 @@ private slots:
QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); painter.end();
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - 31; x < image.width(); ++x) count += image.pixelColor(x, y) == color;
for (int x = image.width() - 64; x < image.width() - 36; ++x) count += image.pixelColor(x, y) == color;
return count;
};
const int known = CHAT_MEMBER_FLAG_PHONE_KNOWN, busy = CHAT_MEMBER_FLAG_PHONE_BUSY;
@@ -540,7 +540,8 @@ private slots:
action == 2 ? &AccountDelegate::callRequested : &AccountDelegate::radioMuteRequested;
QSignalSpy activated(&delegate, signal);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(action == 0 ? row.left() + 21 : row.right() - (action == 1 ? 50 : action == 2 ? 17 : 82), row.center().y());
const QPoint radio(action == 0 ? row.left() + GroupedMemberModel::DisclosureWidth + 21 :
row.right() - (action == 1 ? 82 : action == 2 ? 50 : 18), row.center().y());
QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
for (QPoint point : {radio + QPoint(-15, 0), radio}) {
QMouseEvent move(QEvent::MouseMove, point, view.viewport()->mapToGlobal(point), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
@@ -566,14 +567,14 @@ private slots:
});
const QModelIndex index = model.index(0, 0);
const QRect row = view.visualRect(index);
const QPoint button(row.right() - 82, row.center().y());
const QPoint button(row.right() - 18, row.center().y());
auto colorCount = [&](QRgb color) {
QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white);
QStyleOptionViewItem option; option.initFrom(&view); option.rect = row;
QPainter painter(&image); delegate.paint(&painter, option, index); painter.end();
int count = 0;
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) count += image.pixel(x, y) == color;
for (int x = row.right() - 31; x < row.right() - 3; ++x) count += image.pixel(x, y) == color;
return count;
};
QVERIFY(colorCount(QColor("#9E9E9E").rgb()) > 0); // Молчащий участник серый; зелёный означает передачу.
+190 -2
View File
@@ -1,5 +1,11 @@
/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
#include "../src/accountlist.h"
#include "../src/accountdelegate.h"
#include "../src/devgroupdialog.h"
#include <QInputDialog>
#include <QDialogButtonBox>
#include <QLabel>
#include <QPushButton>
#include "../src/memberlistmodel.h"
#include "../transport/gui_bridge.h"
#include "../transport/member_edit.h"
@@ -8,6 +14,8 @@
#include <QListView>
#include <QAbstractItemModelTester>
#include <QMenu>
#include <QPainter>
#include <QMouseEvent>
#include <QScopeGuard>
#include <memory>
#include <functional>
@@ -16,12 +24,15 @@ extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_member.h"
#include "chat/chat_devgroup.h"
#include "member_files/protocol.h"
#include "debug_config.h"
#include "mem.h"
}
static UTUN_INSTANCE instance{};
static std::vector<chat_devgroup_request> devgroupRequests;
static gui_dm_messages_fn devgroupCallback;
static std::vector<chat_member_detail_req> requests;
static gui_member_detail_fn detailCallback;
static bool ready = true;
@@ -29,17 +40,19 @@ static bool rejectOwned = false;
static int ownedPosts = 0;
static std::vector<chat_member_list_req> memberRequests;
static std::vector<chat_connect_node_req> connectRequests;
static std::vector<uint64_t> muteRequests;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return ready; }
extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; }
extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {}
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; }
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t node) { muteRequests.push_back(node); return 0; }
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) {
if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast<chat_member_detail_req*>(arg));
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline)
memberRequests.push_back(*static_cast<chat_member_list_req*>(arg));
if (fn == chat_core_connect_node_trampoline) connectRequests.push_back(*static_cast<chat_connect_node_req*>(arg));
if (fn == chat_devgroup_trampoline) devgroupRequests.push_back(*static_cast<chat_devgroup_request*>(arg));
u_free(arg);
}
extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) {
@@ -123,10 +136,58 @@ class MemberDetailTest : public QObject {
}
private slots:
void init() {
requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
requests.clear(); memberRequests.clear(); connectRequests.clear(); muteRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
}
void deviceInvitationOpensPinAutomaticallyAndReturnsEnteredPin() {
QWidget window; devgroupRequests.clear(); initDevgroupUi(&window);
QVERIFY(devgroupCallback && !devgroupRequests.empty());
auto respond = [](quint64 request, const QByteArray& json) {
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), request); bytes[8] = 1; bytes += json;
devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
};
const QByteArray invite = "0123456789abcdef0123456789abcdef";
const QByteArray snapshot = "{\"group\":\"\",\"name\":\"\",\"invitations\":[{\"id\":\"" + invite +
"\",\"peer\":\"0000000000000007\",\"channel\":\"123\",\"outgoing\":0,\"state\":1}]}";
respond(devgroupRequests.back().request_id, snapshot);
auto* dialog = window.findChild<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
auto* box = dialog->findChild<QDialogButtonBox*>(); QVERIFY(box); QVERIFY(!box->button(QDialogButtonBox::Ok)->isEnabled());
dialog->setTextValue("1234"); QVERIFY(box->button(QDialogButtonBox::Ok)->isEnabled()); dialog->accept();
QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_ACCEPT);
QCOMPARE(QByteArray(devgroupRequests.back().text), invite); QCOMPARE(QByteArray(devgroupRequests.back().pin), QByteArray("1234"));
// Неверный PIN возвращает waiting: поле появляется повторно, сетевые повторы его не дублируют.
respond(devgroupRequests.back().request_id, snapshot);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
dialog = window.findChild<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
dialog->reject();
devgroupCallback(nullptr, 0); respond(devgroupRequests.back().request_id, snapshot);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QVERIFY(!window.findChild<QInputDialog*>("devgroupIncomingPin"));
}
void addMyDeviceImmediatelyRequestsInvitationAndShowsPin() {
QWidget parent; devgroupRequests.clear(); inviteMyDevice(&parent, "123", 7);
QVERIFY(!devgroupRequests.empty()); const auto request = devgroupRequests.back();
QCOMPARE(request.action, CHAT_DEVGROUP_INVITE); QCOMPARE(request.group, uint64_t(123)); QCOMPARE(request.peer, uint64_t(7));
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), request.request_id); bytes[8] = 1;
bytes += "{\"id\":\"0123456789abcdef0123456789abcdef\",\"pin\":\"1234\"}";
devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
bool shown = false; for (auto* label : parent.findChildren<QLabel*>()) shown |= label->text().contains("1234");
QVERIFY(shown);
}
void disconnectSelfUsesLeaveCommandAndDisablesButton() {
QWidget parent; QPushButton button("Disconnect from my devices", &parent); devgroupRequests.clear();
disconnectMyDevice(&button);
QVERIFY(!button.isEnabled()); QVERIFY(!devgroupRequests.empty());
QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_LEAVE);
QCOMPARE(devgroupRequests.back().peer, uint64_t(0));
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1;
bytes += "{}"; devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
QVERIFY(!button.isEnabled());
}
void actualRouteText_data() {
QTest::addColumn<int>("up"); QTest::addColumn<int>("hops"); QTest::addColumn<int>("localNat"); QTest::addColumn<int>("peerNat");
QTest::addColumn<int>("rtt"); QTest::addColumn<bool>("online"); QTest::addColumn<QString>("compact"); QTest::addColumn<QString>("detail");
@@ -156,6 +217,131 @@ 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 devgroupControlsFollowFirstOnlineDeviceAndKeepFirstAvatar() {
AccountList panel; panel.resize(380, 350); panel.show();
auto first = row(7, false), second = row(8), third = row(9);
for (auto* bytes : {&first, &second, &third}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
fill(panel, "123", first + second + third + row(10));
auto* view = panel.findChild<QListView*>();
auto* grouped = qobject_cast<GroupedMemberModel*>(view->model()); QVERIFY(grouped);
QSignalSpy calls(&panel, &AccountList::callRequested), radioCalls(&panel, &AccountList::radioCallRequested);
const auto group = grouped->index(0);
QCOMPARE(group.data(Qt::DisplayRole).toString(), QString("My devices"));
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(8));
QCOMPARE(grouped->avatarSourceRow(0), 0); QCOMPARE(grouped->sourceRow(0), -1);
const auto avatar = qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage();
QCOMPARE(avatar, qvariant_cast<QIcon>(panel.model()->index(0).data(Qt::DecorationRole)).pixmap(34, 34).toImage());
QTest::qWait(10);
const QRect rect = view->visualRect(group);
const QPoint call(rect.right() - 49, rect.center().y()), radio(rect.right() - 81, rect.center().y());
const QPoint mute(rect.right() - 17, rect.center().y());
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(8));
QCOMPARE(calls.last()[2].toString(), QString("peer8"));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QCOMPARE(radioCalls.size(), 1); QCOMPARE(radioCalls.last()[1].toULongLong(), quint64(8));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(muteRequests.size(), size_t(1)); QCOMPARE(muteRequests.back(), uint64_t(8));
QCOMPARE(grouped->rowCount(), 2); // Кнопки не раскрывают группу.
QTest::mousePress(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
second[8] = 0; // Устройство уходит offline между press и release.
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(second.constData()));
QTest::mouseRelease(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1);
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(9));
QCOMPARE(qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(), avatar);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 2); QCOMPARE(calls.last()[1].toULongLong(), quint64(9));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 12, rect.center().y()));
QCOMPARE(grouped->rowCount(), 5); QVERIFY(grouped->index(0).data(GroupedMemberModel::GroupExpandedRole).toBool());
const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_SCREENSHOT");
if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot));
third[8] = 0; panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(third.constData()));
QCOMPARE(grouped->index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(0));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(calls.size(), 2); QCOMPARE(muteRequests.size(), size_t(1));
}
void devgroupIndicatorsCombineDevicesEvenWhenFirstOnlineIsSelf() {
MemberListModel source; source.setChannel("123", true);
GroupedMemberModel grouped(&source);
auto self = row(7, true, 0, 0xffff, 255, 0, true), busy = row(8), removed = row(9);
for (auto* bytes : {&self, &busy, &removed}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
const int active = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
busy[10] = char(active | CHAT_MEMBER_FLAG_RADIO_MUTED);
removed[10] = char(active | CHAT_MEMBER_FLAG_DELETED);
const auto bytes = self + busy + removed;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
const auto header = grouped.index(0);
QVERIFY(header.data(Qt::UserRole + 2).toBool());
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), active); // mute относится только к цели кнопок.
AccountDelegate delegate;
QStyleOptionViewItem option; option.rect = QRect(0, 0, 340, MemberListModel::kRowHeight);
const QColor background("#FFFFFF");
option.palette.setColor(QPalette::AlternateBase, background);
auto image = [&] {
QImage result(option.rect.size(), QImage::Format_RGB32); result.fill(background);
QPainter painter(&result); delegate.paint(&painter, option, header); painter.end(); return result;
};
auto colors = [](const QImage& image, int rightOffset, const QColor& color) {
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - rightOffset - 1; x < image.width() - rightOffset + 27; ++x)
count += image.pixelColor(x, y) == color;
return count;
};
const auto activeImage = image();
QVERIFY(colors(activeImage, 95, QColor("#4CAF50")) > 0);
QVERIFY(colors(activeImage, 63, QColor("#E53935")) > 0);
QVERIFY(colors(activeImage, 31, QColor("#9E9E9E")) > 0);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested), toggles(&delegate, &AccountDelegate::groupToggleRequested);
for (int offset : {81, 49, 17}) {
const QPoint point(option.rect.right() - offset, option.rect.center().y());
QMouseEvent press(QEvent::MouseButtonPress, point, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QMouseEvent release(QEvent::MouseButtonRelease, point, point, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
delegate.editorEvent(&press, &grouped, option, header); delegate.editorEvent(&release, &grouped, option, header);
}
QCOMPARE(calls.size(), 0); QCOMPARE(toggles.size(), 0); // Индикаторы self не вызывают себя и не раскрывают группу.
busy[10] = CHAT_MEMBER_FLAG_PHONE_KNOWN;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), int(CHAT_MEMBER_FLAG_PHONE_KNOWN));
QCOMPARE(colors(image(), 63, QColor("#E53935")), 0); // Удалённый busy-аккаунт не влияет на группу.
QCOMPARE(colors(image(), 95, QColor("#4CAF50")), 0); // self не окрашивает выключенную рацию в зелёный.
self[8] = 0; busy[8] = 0;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(self.constData()));
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QVERIFY(!header.data(MemberListModel::OnlineRole).toBool());
const auto offlineImage = image();
for (int offset : {95, 63}) QCOMPARE(colors(offlineImage, offset, background), 28 * offlineImage.height());
}
void flatModelPreservesRowsSnapshotsAndPersistentIndexes() {
MemberListModel model;
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
@@ -427,3 +613,5 @@ private slots:
QTEST_MAIN(MemberDetailTest)
#include "test_member_detail.moc"
void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb) { devgroupCallback = cb; }
@@ -15,6 +15,7 @@
extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_devgroup.h"
#include "debug_config.h"
#include "mem.h"
}
@@ -138,6 +139,23 @@ private slots:
}
void init() { allocationBaseline = u_get_allocated_count(); }
void cleanup() { QCoreApplication::processEvents(); QCOMPARE(u_get_allocated_count(), allocationBaseline); }
void deviceActionMatchesSelfAndDisconnectsCommittedGroup() {
MemberCore core; QVERIFY(core.start());
QVERIFY(!core.run([&] { return chat_devgroup_rename(core.instance, "Devices"); }));
auto dialog = makeDialog(core, true);
auto* button = dialog->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Disconnect from my devices")); button->click(); QVERIFY(!button->isEnabled());
QCOMPARE(core.run([&] {
devgroup_record record; uint8_t ed[32], x[32]; return devgroup_load_local(core.instance, &record, ed, x);
}), 0);
QCoreApplication::processEvents();
auto ungrouped = makeDialog(core, true);
QVERIFY(!ungrouped->findChild<QPushButton*>("memberAddMyDevice")->isEnabled());
core.db.setMyNodeId(0); auto other = makeDialog(core, true);
button = other->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Add device"));
}
void saves_data() {
QTest::addColumn<bool>("properties"); QTest::addColumn<QString>("failure");
for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"})
@@ -150,14 +150,14 @@ private slots:
connect(&delegate, &AccountDelegate::radioMuteRequested, &view, [&](const QModelIndex&) {
accepted = gui_bridge_toggle_member_radio_mute(probe.channel.toUtf8().constData(), 42);
});
const QRect row = view.visualRect(source); const QPoint button(row.right() - 82, row.center().y());
const QRect row = view.visualRect(source); const QPoint button(row.right() - 18, row.center().y());
auto iconImage = [&] {
return view.viewport()->grab().toImage().copy(QRect(row.right() - 95, row.center().y() - 14, 28, 28));
return view.viewport()->grab().toImage().copy(QRect(row.right() - 31, row.center().y() - 14, 28, 28));
};
auto iconHasColor = [&](const QColor& color) {
const QImage image = view.viewport()->grab().toImage();
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) if (image.pixelColor(x, y) == color) return true;
for (int x = row.right() - 31; x < row.right() - 3; ++x) if (image.pixelColor(x, y) == color) return true;
return false;
};
QVERIFY(iconHasColor(QColor("#9E9E9E"))); QVERIFY(!iconHasColor(QColor("#4CAF50")));
+24
View File
@@ -3,6 +3,10 @@
<TS version="2.1" language="en_US" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Expand devices</source><translation>Expand devices</translation></message>
<message><source>Collapse devices</source><translation>Collapse devices</translation></message>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message>
<source>Lookback time:</source>
<translation>Lookback time:</translation>
@@ -2858,4 +2862,24 @@ See log:
<translation>PNG preserves the original pixels and transparency.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN was not accepted. Try again.</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN shown on the inviting device</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>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>Core unavailable</source><translation>Core unavailable</translation></message>
</context>
<context>
<name>AccountList</name>
</context>
</TS>
+24
View File
@@ -3,6 +3,10 @@
<TS version="2.1" language="ru_RU" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Expand devices</source><translation>Развернуть устройства</translation></message>
<message><source>Collapse devices</source><translation>Свернуть устройства</translation></message>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message>
<source>Lookback time:</source>
<translation>Время анализа:</translation>
@@ -2868,4 +2872,24 @@ See log:
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN не принят. Попробуйте ещё раз.</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN с экрана приглашающего устройства</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>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>Core unavailable</source><translation>Ядро недоступно</translation></message>
</context>
<context>
<name>AccountList</name>
</context>
</TS>
+1
View File
@@ -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; }