Add signed device groups and compact NODEINFO packing

This commit is contained in:
evgeny committed 2026-11-02 18:47:58 +01:00
1 parent ef34f81c90
commit d7006aecb5
52 files changed
+1979 -94

No files matched your search

+57 -48
View File
@@ -1,48 +1,57 @@
Внедрение группы устройств в чат (devgroup).
Первый этап: объединение устройств одного пользователя и группировка в списке участников.
1. Идентичность и хранение.
Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей:
X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу.
Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи.
Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи).
Одного совпадения публичного ключа devgroup недостаточно.
Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте.
2. Приглашение.
A выбирает известное устройство B и показывает PIN из 4 цифр. B получает приглашение и вводит PIN с экрана A.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
После проверки A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех.
Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются.
Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет.
3. Профиль и версии.
Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup
с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы.
Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)).
Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается.
Формат должен учитывать текущие ограничения userinfo: строковые значения и общий размер 1024 байта вместе с остальными свойствами.
4. Репликация первого этапа.
Оба публичных ключа, удостоверение устройства и подписанный профиль передаются в подписанном userinfo участника.
Устройство публикует их во всех своих каналах. Более свежий проверенный профиль своей devgroup принимает
и переносит в собственные записи остальных каналов, не меняя чужие записи и не перепубликуя неизменившиеся данные.
Это работает по связанной цепочке общих каналов; независимая от каналов сеть устройств в первый этап не входит.
При создании канала и вступлении в новый канал сразу публикуется актуальная принадлежность.
5. Выход.
Добровольный выход удаляет локальные ключи и devgroup-поля из всех собственных member-записей с повышением их версий.
Локальное состояние и публикации меняются атомарно; уведомления и распространение выполняются после commit.
Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап.
6. Список участников и границы этапа.
В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup.
Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств;
заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство.
Звонки на все устройства и объединение DM в первый этап не входят.
Порядок реализации: состояние и проверка devgroup → профиль и репликация → приглашения → API/headless → Qt и Android.
Проверить подделки, конфликты версий, offline-сходимость, повторы, потери ответов, перезапуск, ошибки сохранения и выход.
Логи показывают состояния и причины отказов, но не PIN и секреты.
Внедрение группы устройств в чат (devgroup).
Первый этап: объединение устройств одного пользователя и группировка в списке участников.
1. Идентичность и хранение.
Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей:
X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу.
Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи.
Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи).
Одного совпадения публичного ключа devgroup недостаточно.
Публичные данные devgroup хранятся в отдельных полях nodes: оба публичных ключа, удостоверение устройства, JSON профиля и его подпись.
Эти же данные входят в TOPO_NODE и подписанный снимок node_snapshots; nodes содержит их согласованную проекцию.
Принадлежность общая для узла, независимо от каналов; в peers_<channel_id> и userinfo поля devgroup не добавляются.
Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте.
2. Приглашение.
A выбирает известное устройство B и показывает PIN из 4 цифр. B получает приглашение и вводит PIN с экрана A.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
После проверки A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех.
Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются.
Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет.
3. Профиль и версии.
Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup
с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы.
Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)).
Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается.
Для профиля devgroup задаётся отдельный ограниченный размер с учётом полного NODEINFO; лимит userinfo менять не требуется.
Версия профиля devgroup и timestamp подписанного снимка узла независимы.
4. Репликация первого этапа.
Данные devgroup распространяются через текущую синхронизацию nodes (NODEINFO/BGP) в составе общей подписанной записи узла.
Текущих гарантий доставки достаточно для первого этапа; отдельный протокол синхронизации и offline-доставки не добавляется.
Проверяются подпись узла, удостоверение принадлежности и подпись профиля devgroup. Обновление снимка заменяет блок целиком.
Более свежий профиль своей devgroup устройство принимает и публикует в новом собственном снимке во всех своих топологических группах.
Чужие снимки не меняются; неизменившийся профиль не вызывает новой публикации.
Синхронизация зависит от распространения NODEINFO через существующие группы; отдельная сеть устройств в первый этап не входит.
После восстановления связи и при вступлении в новую группу используется штатный обмен NODEINFO.
5. Выход.
Добровольный выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp.
Локальное состояние, снимок node_snapshots и поля nodes меняются атомарно; уведомления и распространение выполняются после commit.
Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап.
6. Список участников и границы этапа.
В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup.
Принадлежность берётся из nodes по node_id; профиль выбирается простым поиском наиболее свежего проверенного объявления
среди текущих записей известных узлов devgroup по порядку из п. 3. Отдельный постоянный кеш профилей не нужен.
Изменение общей записи обновляет отображение участника во всех каналах, где он присутствует.
Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств;
заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство.
Звонки на все устройства и объединение DM в первый этап не входят.
Порядок реализации: nodes/TOPO_NODE/снимок и проверка devgroup → NODEINFO/BGP и профиль → приглашения → API/headless → Qt и Android.
Проверить подделки, конфликты версий, обновление через штатный обмен NODEINFO, повторы, потери ответов, перезапуск, ошибки сохранения и выход.
Логи показывают состояния и причины отказов, но не PIN и секреты.
+24
View File
@@ -167,3 +167,27 @@ member-записи не должны восстанавливать намер
REALITY использует полное SNI до 255 символов; размер поля в записи узла
согласован с транспортом. Если локального TCP listen-сокета нет, NCD может
создать исходящий TCP-линк с выбором локального адреса операционной системой.
Devgroup входит в общую подписанную запись: Ed25519/X25519 public keys,
удостоверение данного устройства, подпись профиля и исходные байты JSON.
В `nodes` хранятся `devgroup_ed`, `devgroup_x`, `devgroup_member_sig`,
`devgroup_json`, `devgroup_profile_sig`; отсутствие группы — NULL во всех пяти полях.
Проекция меняется вместе с `node_snapshots` в одной транзакции. Локальные секреты
находятся в `devgroup_local`, квитанции завершённого импорта — в `devgroup_receipts`.
Квитанции сохраняются после выхода: повтор передачи не восстанавливает членство.
Выбор имени: максимум (ver,timestamp,SHA256(JSON)) среди проверенных объявлений `nodes`.
NODEINFO упакован без выравнивания структур. Заголовок содержит uint16
`reality_size` и `devgroup_size`, затем прежние поля. После заголовка идут
`node_name_len` байт имени (без NUL), `devgroup_size` байт devgroup, metadata и адреса,
REALITY, опциональные подсети и список переходов. Пустой devgroup занимает 0 байт тела.
Непустой блок: Ed32, X32, удостоверение64, подпись JSON64, JSON без NUL (до768 байт).
REALITY: socket_id1, short_id8, server_pubkey32, version3, server_name_len1,
затем ровно server_name_len байт SNI без NUL. Прежний char[256] на проводе удалён.
Для SNI длиной N экономия составляет 255−N байт на сокет; `reality_size` добавляет
2 байта один раз в заголовке. Например, для example.com экономия244 байта на сокет.
Имя самого узла уже передавалось переменной длиной; его упаковка сохранена.
Парсер проверяет длину секции, число записей и отсутствие NUL внутри переданного имени.
Локальный GUI/JNI-снимок участника имеет свой формат, описанный в `chat_member.h`;
он не передаётся в BGP и не является NODEINFO.
+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
+282
View File
@@ -0,0 +1,282 @@
#include "chat_devgroup.h"
#include "chat_core_priv.h"
#include "chat_event.h"
#include "member_sync.h"
#include "../utun_instance.h"
#include "../routing_layer/route_crypto.h"
#include "../transport_layer/etcp.h"
#include "../../lib/mem.h"
#include "../../lib/u_async.h"
#include "../../lib/strbuf.h"
#include <openssl/rand.h>
#include <openssl/crypto.h>
#define DG_SLOTS 16
#define DG_TTL 1200000ULL
#define DG_RETRY 30000ULL
#define DG_OFFER 1
#define DG_PIN 2
#define DG_KEYS 3
#define DG_RESULT 4
/* state: 1=ожидается PIN,2=отправлен PIN,3=переданы ключи,4=успех,5=отказ,6=истёк,7=результат неизвестен. */
struct dg_invite {
uint8_t id[16], x[32], ed[32], group_ed[32];
uint64_t group, peer, expires, sent;
unsigned state, outgoing, attempts;
uint8_t last_pin[4], last_result; /* Повторы одной попытки получают прежний результат. */
char pin[5]; /* Только локальная память; никогда не выводится в журнал. */
};
struct chat_devgroup {
struct UTUN_INSTANCE* inst;
struct dg_invite slots[DG_SLOTS];
void* timer;
unsigned ticks;
};
static struct chat_devgroup* dg_ctx(struct UTUN_INSTANCE* inst) { return inst && CC(inst) ? CC(inst)->devgroup : NULL; }
static void dg_hex(const uint8_t* in, size_t n, char* out) { for (size_t i = 0; i<n; i++) snprintf(out + 2*i, 3,"%02x", in[i]); }
static int dg_id(const char* text, uint8_t out[16]) {
return !text || strlen(text) != 32 || strspn(text,"0123456789abcdef") != 32 || sc_hex_to_binary(text, out, 16) ? -1 : 0;
}
static int dg_peer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t x[32], uint8_t ed[32]) {
char channel[32]; snprintf(channel, sizeof(channel),"%llu", (unsigned long long)group);
struct TOPO_NODE* node = topo_node_registry_find(inst->topo_groups, peer);
if (!node || peer == inst->node_id || member_sync_verified_ed25519(inst, channel, peer, ed) || memcmp(ed, node->ed25519_public_key, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer identity unavailable group=%llu peer=%016llx", (unsigned long long)group, (unsigned long long)peer);
return -1;
}
memcpy(x, node->public_key, 32); return 0;
}
static struct dg_invite* dg_find(struct chat_devgroup* ctx, const uint8_t id[16]) {
for (unsigned i = 0; i<DG_SLOTS; i++) if (ctx->slots[i].state && !memcmp(ctx->slots[i].id, id, 16)) return &ctx->slots[i];
return NULL;
}
static struct dg_invite* dg_slot(struct chat_devgroup* ctx, uint64_t peer) {
struct dg_invite* free_slot = NULL;
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i];
if (p->state && p->state<4 && p->peer == peer) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer already has pending invitation"); return NULL; }
if (!p->state || (p->state >= 4 && get_time_tb()>p->expires)) free_slot = p;
}
if (!free_slot) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation capacity reached");
if (free_slot) OPENSSL_cleanse(free_slot, sizeof(*free_slot));
return free_slot;
}
static int dg_send(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t kind, const uint8_t* body, size_t n) {
uint8_t x[32], ed[32];
p->sent = get_time_tb();
if (dg_peer(ctx->inst, p->group, p->peer, x, ed) || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation peer keys changed"); return -1;
}
struct ll_entry* entry = ll_alloc_lldgram(18 + n);
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: packet allocation failed"); return -1; }
entry->dgram[0] = ETCP_RT_ID_DEVGROUP; entry->dgram[1] = kind; memcpy(entry->dgram + 2, p->id, 16);
if (n) memcpy(entry->dgram + 18, body, n);
entry->len = 18 + n;
char id[33]; dg_hex(p->id, 16, id);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup TX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu",
id, (unsigned long long)p->peer, kind, p->state, n);
int rc = etcp_route_send(ctx->inst, p->group, p->peer, entry, 0, ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT);
if (rc) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: send failed peer=%016llx kind=%u", (unsigned long long)p->peer, kind);
return rc;
}
static void dg_changed(struct chat_devgroup* ctx) { chat_event_post(ctx->inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0); }
static int dg_keys(struct chat_devgroup* ctx, struct dg_invite* p) {
struct devgroup_record r; uint8_t ep[32], xp[32], data[64 + DEVGROUP_WIRE_MAX];
int rc = devgroup_load_local(ctx->inst, &r, ep, xp);
if (rc != 1 || memcmp(r.ed, p->group_ed, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: pending transfer cancelled after local membership changed"); p->state = 7; rc = -1;
} else {
int n = devgroup_encode(&r, data + 64, DEVGROUP_WIRE_MAX);
if (n<0) rc = -1;
else { memcpy(data, ep, 32); memcpy(data + 32, xp, 32); rc = dg_send(ctx, p, DG_KEYS, data, 64 + n); }
}
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); OPENSSL_cleanse(data, sizeof(data)); return rc;
}
static void dg_result(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t code) {
uint8_t result[2] = {(uint8_t)p->attempts, code};
dg_send(ctx, p, DG_RESULT, result, sizeof(result));
}
static void dg_send_pin(struct chat_devgroup* ctx, struct dg_invite* p) {
uint8_t body[5] = {(uint8_t)p->attempts}; memcpy(body + 1, p->pin, 4);
dg_send(ctx, p, DG_PIN, body, sizeof(body)); OPENSSL_cleanse(body, sizeof(body));
}
static void dg_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
struct chat_devgroup* ctx = conn ? dg_ctx(conn->instance) : NULL;
if (!ctx || !entry || !entry->dgram) goto out;
if (entry->len == ROUTER_SVC_HDR_SIZE) goto out; /* reset маршрутизатора: retry остаётся у операции */
if (entry->len<ROUTER_SVC_HDR_SIZE + 17 || (entry->dgram[ROUTER_SVC_FLAGS_OFF]&(ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) != (ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto bad;
uint64_t peer, group; memcpy(&peer, entry->dgram + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, entry->dgram + ROUTER_SVC_GROUP_OFF, 8);
uint8_t x[32], ed[32]; if (dg_peer(ctx->inst, group, peer, x, ed)) goto out;
const uint8_t* data = entry->dgram + ROUTER_SVC_HDR_SIZE; uint8_t kind = *data++;
const uint8_t* id = data; data+=16; size_t n = entry->len-ROUTER_SVC_HDR_SIZE-17;
struct dg_invite* p = dg_find(ctx, id);
char id_hex[33]; dg_hex(id, 16, id_hex);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup RX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu",
id_hex, (unsigned long long)peer, kind, p ? p->state : 0, n);
if (p && (p->peer != peer || p->group != group || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32))) goto bad;
if (kind == DG_KEYS) {
int receipt = chat_devgroup_receipt(ctx->inst, id, peer);
if (receipt<0) goto out;
if (receipt) {
struct dg_invite reply = {.group = group, .peer = peer}; memcpy(reply.id, id, 16); memcpy(reply.x, x, 32); memcpy(reply.ed, ed, 32);
dg_result(ctx, &reply, 0); goto out;
}
}
if (kind == DG_OFFER) {
if (n) goto bad;
if (!p) {
p = dg_slot(ctx, peer); if (!p) goto out;
p->state = 1; p->group = group; p->peer = peer; p->expires = get_time_tb() + DG_TTL;
memcpy(p->id, id, 16); memcpy(p->x, x, 32); memcpy(p->ed, ed, 32);
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation received peer=%016llx", (unsigned long long)peer);
dg_changed(ctx);
}
goto out;
}
if (!p || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: expired/unknown invitation packet kind=%u", kind); goto out; }
if (kind == DG_PIN && p->outgoing) {
if (n != 5 || data[0] < 1 || data[0] > 3) goto bad;
if (p->state == 1 || p->state == 5) {
if (data[0] == p->attempts) {
if (CRYPTO_memcmp(data + 1, p->last_pin, 4)) goto bad;
dg_result(ctx, p, p->last_result); goto out;
}
if (data[0] != p->attempts + 1) goto bad;
}
if (p->state == 3) { dg_keys(ctx, p); goto out; }
if (p->state == 4) { dg_result(ctx, p, 0); goto out; }
if (p->state != 1) { dg_result(ctx, p, 2); goto out; }
p->attempts = data[0]; memcpy(p->last_pin, data + 1, 4);
if (CRYPTO_memcmp(data + 1, p->pin, 4)) {
if (p->attempts >= 3) p->state = 5;
p->last_result = p->state == 5 ? 2 : 1;
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: PIN rejected peer=%016llx attempt=%u", (unsigned long long)peer, p->attempts);
dg_result(ctx, p, p->state == 5 ? 2 : 1); dg_changed(ctx); goto out;
}
struct devgroup_record own; uint8_t ep[32], xp[32]; int has = devgroup_load_local(ctx->inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
if (has != 1 || memcmp(p->group_ed, own.ed, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled after membership changed");
p->state = 5; dg_result(ctx, p, 2); dg_changed(ctx); goto out;
}
p->state = 3; OPENSSL_cleanse(p->pin, sizeof(p->pin));
dg_keys(ctx, p); dg_changed(ctx);
} else if (kind == DG_KEYS && !p->outgoing && p->state == 2) {
if (n <= 64) goto bad;
struct devgroup_record r;
if (devgroup_decode(&r, data + 64, n-64)) goto bad;
int rc = chat_devgroup_import(ctx->inst, &r, data, data + 32, p->id, p->peer);
p->state = rc ? 5 : 4; OPENSSL_cleanse(p->pin, sizeof(p->pin)); dg_result(ctx, p, rc ? 2 : 0); dg_changed(ctx);
} else if (kind == DG_RESULT) {
if (n != 2 || data[1] > 2) goto bad;
if (p->outgoing && p->state == 3) p->state = data[1] ? 7 : 4;
else if (!p->outgoing && p->state == 2 && data[0] == p->attempts && data[1]) {
p->state = data[1] == 1 ? 1 : 5; OPENSSL_cleanse(p->pin, sizeof(p->pin));
}
else goto out;
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation result peer=%016llx state=%u", (unsigned long long)peer, p->state); dg_changed(ctx);
} else goto bad;
goto out;
bad:
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: malformed/unexpected invitation packet bytes=%u", entry ? entry->len : 0);
out:
if (entry) { if (entry->dgram) OPENSSL_cleanse(entry->dgram, entry->len); queue_dgram_free(entry); queue_entry_free(entry); }
}
static void dg_tick(void* arg) {
struct chat_devgroup* ctx = arg; ctx->timer = NULL; uint64_t now = get_time_tb();
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state || p->state >= 4) continue;
if (now>p->expires) {
p->state = p->state == 3 ? 7 : 6; OPENSSL_cleanse(p->pin, sizeof(p->pin));
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation timeout peer=%016llx state=%u", (unsigned long long)p->peer, p->state);
dg_changed(ctx); continue;
}
if (now-p->sent<DG_RETRY) continue;
if (p->outgoing && p->state == 1) dg_send(ctx, p, DG_OFFER, NULL, 0);
else if (p->outgoing && p->state == 3) dg_keys(ctx, p);
else if (!p->outgoing && p->state == 2) dg_send_pin(ctx, p);
}
if (++ctx->ticks%5 == 0) chat_devgroup_refresh(ctx->inst);
ctx->timer = uasync_set_timeout(ctx->inst->ua, 10000, ctx, dg_tick,"devgroup");
if (!ctx->timer) DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: timer allocation failed");
}
int chat_devgroup_init(struct UTUN_INSTANCE* inst) {
if (!inst || !CC(inst) || CC(inst)->devgroup) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invalid init"); return -1; }
struct chat_devgroup* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: context allocation failed"); return -1; }
ctx->inst = inst; CC(inst)->devgroup = ctx;
if (etcp_router_bind(inst, ETCP_RT_ID_DEVGROUP, dg_receive) || etcp_router_require_crypto(inst, ETCP_RT_ID_DEVGROUP, ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto fail;
ctx->timer = uasync_set_timeout(inst->ua, 10000, ctx, dg_tick,"devgroup"); if (!ctx->timer) goto fail;
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service started"); return 0;
fail:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: service initialization failed"); chat_devgroup_destroy(inst); return -1;
}
void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return;
for (unsigned i = 0; i < DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state || p->state >= 4) continue;
p->state = p->state == 3 ? 7 : 5;
OPENSSL_cleanse(p->pin, sizeof(p->pin)); OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin));
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled peer=%016llx state=%u", (unsigned long long)p->peer, p->state);
}
dg_changed(ctx);
}
void chat_devgroup_destroy(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return;
chat_devgroup_cancel_pending(inst);
if (ctx->timer) uasync_cancel_timeout(inst->ua, ctx->timer);
etcp_router_unbind(inst, ETCP_RT_ID_DEVGROUP); CC(inst)->devgroup = NULL;
OPENSSL_cleanse(ctx, sizeof(*ctx)); u_free(ctx); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service stopped");
}
int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]) {
struct chat_devgroup* ctx = dg_ctx(inst); uint8_t x[32], ed[32];
if (!ctx || !out_id || !out_pin || dg_peer(inst, group, peer, x, ed)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invite rejected"); return -1; }
struct dg_invite* p = dg_slot(ctx, peer); if (!p) return -1;
struct devgroup_record own; uint8_t ep[32], xp[32];
int has = devgroup_load_local(inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
if (!has) {
if (chat_devgroup_rename(inst, inst->name[0] ? inst->name : "My devices")) return -1;
has = devgroup_load_local(inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
}
if (has != 1) return -1;
memcpy(p->group_ed, own.ed, 32);
uint32_t random;
do { if (RAND_bytes((uint8_t*)&random, sizeof(random)) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: PIN randomness failed"); return -1; } }
while (random >= UINT32_MAX-UINT32_MAX%10000);
if (RAND_bytes(p->id, 16) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invitation randomness failed"); return -1; }
snprintf(p->pin, sizeof(p->pin),"%04u", random%10000); p->group = group; p->peer = peer; p->outgoing = 1; p->state = 1; p->expires = get_time_tb() + DG_TTL;
memcpy(p->x, x, 32); memcpy(p->ed, ed, 32); dg_hex(p->id, 16, out_id); memcpy(out_pin, p->pin, 5);
dg_send(ctx, p, DG_OFFER, NULL, 0); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation created peer=%016llx", (unsigned long long)peer);
dg_changed(ctx); return 0;
}
int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char* pin) {
struct chat_devgroup* ctx = dg_ctx(inst); uint8_t binary[16];
if (!ctx || dg_id(id, binary) || !pin || strlen(pin) != 4 || strspn(pin,"0123456789") != 4) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invalid acceptance arguments"); return -1;
}
struct dg_invite* p = dg_find(ctx, binary);
if (!p || p->outgoing || p->state != 1 || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: acceptance unavailable"); return -1; }
if (p->attempts >= 3) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: PIN attempts exhausted"); return -1; }
memcpy(p->pin, pin, 5); p->attempts++; p->state = 2;
dg_send_pin(ctx, p); dg_changed(ctx); return 0;
}
char* chat_devgroup_status(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: status unavailable"); return NULL; }
struct devgroup_record r; struct devgroup_profile profile; uint8_t ep[32], xp[32]; char id[65] = "";
int found = devgroup_load_local(inst, &r, ep, xp); OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); if (found<0) return NULL;
struct strbuf json = {0};
if (found) { if (devgroup_profile_read(&r, &profile)) return NULL; dg_hex(r.ed, 32, id); }
if (strbuf_addf(&json,"{\"group\":\"%s\",\"name\":", id)<0 || strbuf_add_json_string(&json, found ? profile.name : "") || strbuf_addf(&json,",\"invitations\":[")<0) goto fail;
unsigned count = 0;
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state) continue; dg_hex(p->id, 16, id);
if (strbuf_addf(&json,"%s{\"id\":\"%s\",\"peer\":\"%016llx\",\"channel\":\"%llu\",\"outgoing\":%u,\"state\":%u}",
count++ ? "," : "", id, (unsigned long long)p->peer, (unsigned long long)p->group, p->outgoing, p->state)<0) goto fail;
}
if (strbuf_addf(&json,"]}")<0) goto fail;
return strbuf_detach(&json);
fail:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: status allocation failed"); strbuf_free(&json); return NULL;
}
+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-сведения
+4
View File
@@ -392,6 +392,8 @@ int topo_node_sqlite_init(sqlite3* db) {
return -1;
}
if (devgroup_schema(db)) return -1;
/* Старые подписи непригодны; адреса и максимальные версии остаются для bootstrap и новой публикации. */
if (legacy_profile) {
const char *migration = "SAVEPOINT compact_nat_profile;"
@@ -1319,7 +1321,9 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) {
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 2, (sqlite3_int64)ni->timestamp);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(stmt, 3, record, len, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(stmt);
int changed = rc == SQLITE_DONE ? sqlite3_changes(db) : 0;
int final = sqlite3_finalize(stmt);
if (rc == SQLITE_DONE && final == SQLITE_OK && changed && devgroup_project(db, ni)) rc = SQLITE_ERROR;
if (rc == SQLITE_DONE && final == SQLITE_OK) {
rc = sqlite3_exec(db, "RELEASE node_snapshot", NULL, NULL, NULL);
if (rc == SQLITE_OK) return 0;
+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;
}
+212
View File
@@ -0,0 +1,212 @@
/* Настоящий router/UDP A ↔ B ↔ C: devgroup E2E, повтор PIN, потеря ACK, NODEINFO и выход. */
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_devgroup.h"
#include "chat/chat_member.h"
#include "routing_layer/route_crypto.h"
#include "chat/member_sync.h"
#include "chat/merkle_sync.h"
#include "chat/db_sync.h"
#include "routing_layer/topo_group.h"
#include "routing_layer/topo_node_sqlite.h"
#include "transport_layer/node_conn_direct.h"
#include "transport_layer/etcp_connections.h"
#include "transport_layer/etcp.h"
#include "../lib/platform_compat.h"
#include "../lib/socket_compat.h"
#include "../lib/debug_config.h"
#include "test_net.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static struct UASYNC* ua;
static struct UTUN_INSTANCE* nodes[4];
static char channel[21];
static void hex(const uint8_t* data, size_t len, char* out) {
for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", data[i]);
}
static struct UTUN_INSTANCE* create_node(unsigned port) {
struct SC_MYKEYS keys; assert(sc_generate_keypair(&keys) == SC_OK);
char private_key[65], public_key[65], config[1024]; hex(keys.private_key, 32, private_key); hex(keys.public_key, 32, public_key);
snprintf(config, sizeof(config),
"[global]\nmy_private_key=%s\nmy_public_key=%s\nkeepalive_adaptive=0\n"
"[server: udp]\naddr=127.0.0.1:%u\ntype=public\n[allowed_keys]\nallow_all=1\n"
"[ntp]\nenabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", private_key, public_key, port);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); return inst;
}
/* Все три заранее принятых участника имеют настоящие подписи root/join. */
static void seed_members(uint8_t channel_x[32], uint8_t channel_ed[32], uint8_t owner_private[32], int count) {
for (int dst = 0; dst < count; dst++) for (int author = 0; author < count; author++) {
uint8_t body[112], join[64], tree[64];
assert(member_sync_build_join_msg(channel_x, channel_ed, nodes[author]->node_id,
nodes[author]->my_keys.public_key, 1, body, sizeof(body)) == 112);
assert(sc_ed25519_sign(nodes[author]->my_ed25519_privkey, body, sizeof(body), join) == SC_OK);
assert(member_sync_sign_pubkey(owner_private, nodes[author]->my_keys.public_key, tree) == 0);
assert(member_sync_put(nodes[dst], channel, nodes[author]->node_id, nodes[author]->my_keys.public_key,
nodes[author]->my_ed25519_pubkey, join, 1, NULL, 0, "{}", NULL, NULL, 0, 0, tree) >= 0);
}
}
/* Поднимаем транспорт только соседних пар; C получает данные A через B. */
static struct NODE_CONN_DIRECT* connect_node(int from, int to, unsigned port) {
struct TOPO_ADDR4 address = { .addr = {127, 0, 0, 1}, .port = port, .protocol = TOPO_PROTO_UDP };
struct TOPO_NODE node = { .node_id = nodes[to]->node_id, .v4_addrs = &address };
memcpy(node.public_key, nodes[to]->my_keys.public_key, 32); struct NODE_CONN_DIRECT* handle = NULL;
assert(node_conn_direct_open_node(nodes[from], node.node_id, NULL, NULL, &handle, &node, NULL) != NCD_ERR); return handle;
}
static int wait_ready(struct TOPO_PEER_REQUEST* handle) {
uint64_t deadline = get_time_tb() + 15u * 10000u;
while (get_time_tb() < deadline) {
if (topo_group_peer_phase(handle) == TOPO_PEER_READY) return 1;
uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: group readiness timeout phase=%d", topo_group_peer_phase(handle)); return 0;
}
/* C открывает групповой запрос после входящего UP; до него адрес B ещё не получен. */
static int wait_transport(struct NODE_CONN_DIRECT* ab, struct NODE_CONN_DIRECT* bc) {
struct ETCP_CONN *a = NULL, *b = NULL;
uint64_t deadline = get_time_tb() + 15u * 10000u;
while (get_time_tb() < deadline) {
a = node_conn_direct_get_conn(ab); b = node_conn_direct_get_conn(bc);
struct ETCP_CONN* c = instance_find_conn(nodes[2], nodes[1]->node_id);
if (a && b && c && a->initialized && b->initialized && c->initialized
&& a->links_up && b->links_up && c->links_up) return 1;
uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: bootstrap timeout ab=%p bc=%p", (void*)a, (void*)b); return 0;
}
static struct ll_entry* held_keys;
static int hold_keys;
static int drop_pin_result = 1, drop_key_ack = 1, duplicate_pin, key_sends;
int __real_etcp_route_send(struct UTUN_INSTANCE*, uint64_t, uint64_t, struct ll_entry*, int, int);
int __wrap_etcp_route_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, struct ll_entry* entry, int ttl, int crypto) {
if (entry->len >= 18 && entry->dgram[0] == ETCP_RT_ID_DEVGROUP) {
assert(crypto == (ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT));
if (entry->dgram[1] == 2) duplicate_pin++;
if (entry->dgram[1] == 3) {
key_sends++;
if (hold_keys && !held_keys) { held_keys = entry; return 0; }
}
if (entry->dgram[1] == 4 && entry->len == 20 &&
((entry->dgram[19] == 1 && drop_pin_result-- > 0) || (entry->dgram[19] == 0 && drop_key_ack-- > 0))) {
queue_dgram_free(entry); queue_entry_free(entry); return 0;
}
}
return __real_etcp_route_send(inst, group, peer, entry, ttl, crypto);
}
static int state(int node, const char* id) {
char* json = chat_devgroup_status(nodes[node]); assert(json);
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(nodes[node]->topo_sqlite_db,
"SELECT json_extract(value,'$.state') FROM json_each(?,'$.invitations') WHERE json_extract(value,'$.id')=?", -1, &st, NULL) == SQLITE_OK);
sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC);
int result = sqlite3_step(st) == SQLITE_ROW ? sqlite3_column_int(st, 0) : 0;
sqlite3_finalize(st); u_free(json); return result;
}
static void wait_state(int node, const char* id, int expected) {
uint64_t until = get_time_tb() + 150000;
while (get_time_tb() < until) { if (state(node, id) == expected) return; uasync_poll(ua, 5); }
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: timeout node=%d state=%d expected=%d", node, state(node, id), expected); abort();
}
static void wait_profile(int node, uint64_t peer, const char* name) {
uint64_t until = get_time_tb() + 150000;
while (get_time_tb() < until) {
uint8_t id[32]; char actual[64];
int rc = devgroup_member(nodes[node]->topo_sqlite_db, peer, id, actual);
assert(rc >= 0); if (!strcmp(actual, name)) return; uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: profile timeout node=%d peer=%016llx", node, (unsigned long long)peer); abort();
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG);
debug_set_category_level(DEBUG_CATEGORY_MEMBER_SYNC, DEBUG_LEVEL_INFO);
ua = uasync_create(); assert(ua); unsigned ports[4];
for (int i = 0; i < 3; i++) {
nodes[i] = create_node(0);
ports[i] = ss_get_port(&test_etcp_bound_socket(nodes[i], "udp")->local_addr);
}
chat_core_create_channel_auto(nodes[0], "Devices");
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(nodes[0]->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW); snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st);
uint8_t x[32], ed[32], signature[64], owner_private[32];
assert(topo_node_sqlite_channel_get(nodes[0]->topo_sqlite_db, channel, NULL, 0, NULL, x, ed, signature) == 0);
assert(topo_node_sqlite_channel_get_priv(nodes[0]->topo_sqlite_db, channel, owner_private) == 0);
for (int i = 1; i < 3; i++) {
assert(topo_node_sqlite_channel_put(nodes[i]->topo_sqlite_db, channel, "Devices", nodes[0]->node_id,
x, NULL, ed, NULL, signature) == 0); chat_core_ensure_channel_ready(nodes[i], channel);
}
seed_members(x, ed, owner_private, 3);
struct NODE_CONN_DIRECT* ab = connect_node(0, 1, ports[1]), *bc = connect_node(1, 2, ports[2]);
assert(wait_transport(ab, bc));
struct TOPO_PEER_REQUEST *groups[5] = {0}; uint64_t gid = strtoull(channel, NULL, 10);
assert(topo_group_peer_open(topo_groups_find(nodes[0]->topo_groups, gid), nodes[1]->node_id, &groups[0]) == 0);
assert(topo_group_peer_open(topo_groups_find(nodes[1]->topo_groups, gid), nodes[2]->node_id, &groups[1]) == 0);
assert(topo_group_peer_open(topo_groups_find(nodes[2]->topo_groups, gid), nodes[1]->node_id, &groups[2]) == 0);
for (int i = 0; i < 3; i++) assert(wait_ready(groups[i]));
/* Ждём снимок C через B до выбора C как известного участника. */
uint64_t until = get_time_tb() + 150000;
while (!topo_node_registry_find(nodes[0]->topo_groups, nodes[2]->node_id) && get_time_tb() < until) uasync_poll(ua, 5);
char id[33], pin[5], wrong[5];
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0';
assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, 1);
assert(duplicate_pin >= 2 && state(0, id) == 1);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4); wait_state(0, id, 4);
assert(key_sends >= 2);
struct devgroup_record r[3]; uint8_t ep[3][32], xp[3][32];
assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1);
assert(devgroup_load_local(nodes[1], &r[1], ep[1], xp[1]) == 0);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 1);
assert(!memcmp(ep[0], ep[2], 32) && !memcmp(xp[0], xp[2], 32));
assert(!chat_devgroup_rename(nodes[2], "New device group"));
wait_profile(0, nodes[2]->node_id, "New device group");
until = get_time_tb() + 150000;
do {
assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1);
if (!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json)) break;
uasync_poll(ua, 5);
} while (get_time_tb() < until);
assert(!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json));
wait_profile(1, nodes[0]->node_id, "New device group");
assert(!chat_devgroup_leave(nodes[2])); wait_profile(0, nodes[2]->node_id, "");
/* После успешного импорта новый ACK может потеряться прямо перед выходом. */
drop_key_ack = 1;
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4);
assert(!chat_devgroup_leave(nodes[2])); wait_state(0, id, 4);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0);
/* Три разные неверные попытки завершают операцию. */
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0';
for (int i = 0; i < 3; i++) { assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, i == 2 ? 5 : 1); }
assert(state(0, id) == 5 && chat_devgroup_accept(nodes[2], id, pin) < 0);
/* Выход до первого commit тоже отменяет согласие: поздние ключи не должны присоединить C. */
hold_keys = 1;
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(0, id, 3); assert(held_keys);
assert(!chat_devgroup_leave(nodes[2]) && state(2, id) == 5); hold_keys = 0;
assert(!__real_etcp_route_send(nodes[0], gid, nodes[2]->node_id, held_keys, 0, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT));
held_keys = NULL;
until = get_time_tb() + 5000; while (get_time_tb() < until) uasync_poll(ua, 5);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0);
assert(!chat_devgroup_leave(nodes[0]) && state(0, id) == 7);
for (int i = 0; i < 3; i++) topo_group_peer_close(groups[i]);
node_conn_direct_close(ab); node_conn_direct_close(bc);
for (int i = 0; i < 3; i++) utun_instance_destroy(nodes[i]);
uasync_poll(ua, 0); uasync_destroy(ua, 0);
puts("PASS devgroup: encrypted signed relay, PIN retries, ACK loss, NODEINFO convergence, leave/replay and three attempts");
return 0;
}
+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 == 13 || type == 70 || type == 71) deliver("devgroup") { com.utun.chat.data.DevgroupRepository.onEvent(type, data) }
if (type == 13 || type in 61..64) deliver("discovery") { com.utun.chat.data.LocalDiscovery.onEvent(type, data) }
if (type == 13 || type in 49..50) deliver("member files") { ChatApplication.instance.memberFiles.onEvent(type, data) }
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) {
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, 70, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) {
deliver("members") { ChatApplication.instance.members.onEvent(type, data) }
deliver("avatars") { ChatApplication.instance.avatars.onEvent(type, data) }
}
@@ -46,7 +46,8 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean =
val directPresence: Boolean = false, val indirectPresence: Boolean = false,
val bgpPresence: Boolean = false, val bgpUp: Boolean = false, val bgpRtt: Int = 0,
val bgpNodes: Int = 0, val connecting: Boolean = false,
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false) {
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false,
val devgroup: String = "", val devgroupName: String = "") {
val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0)
}
@@ -161,14 +162,15 @@ fun parseConnList(data: ByteArray): List<ConnInfo> {
}
/* Размер общего снимка из chat_member.h; одинаковый для desktop и Android. */
const val MEMBER_WIRE_SIZE = 164
const val MEMBER_WIRE_SIZE = 260
data class MemberWire(
val nodeId: Long, val online: Int, val isSelf: Boolean,
val flags: Int, val rtt: Int, val natType: Int,
val storage: Boolean, val name: String,
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false,
val verified: Boolean = false, val nodeType: Int = 0,
val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0
val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0,
val devgroup: String = "", val devgroupName: String = ""
)
fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
@@ -192,13 +194,17 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
val presence = buf.get().toInt() and 0xff
val up = buf.get().toInt() and 0xff
val intermediaries = buf.short.toInt() and 0xffff
val groupBytes = ByteArray(32); buf.get(groupBytes)
val devgroup = if (groupBytes.all { it == 0.toByte() }) "" else groupBytes.joinToString("") { "%02x".format(it.toInt() and 255) }
val groupName = ByteArray(64); buf.get(groupName)
val devgroupName = String(groupName, Charsets.UTF_8).substringBefore('\u0000')
return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile,
storageRole, verified, nodeType, presence, up, intermediaries)
storageRole, verified, nodeType, presence, up, intermediaries, devgroup, devgroupName)
}
/* Полный снимок заменяет все свойства; connecting — локальное состояние запроса GUI. */
fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): ChatMember = previous.copy(
name = name, online = online != 0,
name = name, online = online != 0, devgroup = devgroup, devgroupName = devgroupName,
isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0,
isModer = (flags and 4) != 0, isStorage = storage,
isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, radioMuted = (flags and 0x20) != 0,
@@ -0,0 +1,41 @@
package com.utun.chat.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int)
data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List<DeviceInvitation> = emptyList(),
val pin: String = "", val failed: Boolean = false)
/** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */
object DevgroupRepository {
private val mutable = MutableStateFlow(DevgroupState())
val state = mutable.asStateFlow()
private var nextId = 0L
private val pending = mutableMapOf<Long, Int>()
fun command(action: Int = 0, group: Long = 0, peer: Long = 0, text: String = "", pin: String = "") {
if (action == 0 && pending.containsValue(0)) return
val id = ++nextId
if (NativeLib.devgroupCommand(action, group, peer, text, pin, id)) pending[id] = action
else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true) }
}
fun dismiss() { mutable.value = mutable.value.copy(pin = "", failed = false) }
fun onEvent(type: Int, data: ByteArray?) {
if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return }
if (type == 70) { command(); return }
if (type != 71 || data == null || data.size < 9) return
val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long
val action = pending.remove(id) ?: return
if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true); return }
val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))
if (action == 1) { mutable.value = mutable.value.copy(pin = json.getString("pin"), failed = false); return }
val invitations = json.getJSONArray("invitations")
mutable.value = mutable.value.copy(name = json.getString("name"), member = json.getString("group").isNotEmpty(),
invitations = List(invitations.length()) { i -> val entry = invitations.getJSONObject(i)
DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state")) },
failed = if (action == 0) mutable.value.failed else false)
}
}
@@ -55,6 +55,7 @@ class MemberRepository(
active = false; generation++; loaded.clear(); pending.clear(); _members.value = emptyMap()
log("INFO", "Core stopped: member snapshots cleared generation=$generation"); return
}
if (type == 70) { loaded.toList().forEach(::refresh); return }
if (type !in listOf(23, 24, 25, 31)) return
if (!active) { log("DEBUG", "Ignored member event after stop type=$type"); return }
try {
@@ -491,6 +491,9 @@ object NativeLib {
private external fun nativeConnectMemberNode(channelId: String, nodeId: Long)
private external fun nativeGetChannels(): String?
private external fun nativeGetMessages(channelId: String, limit: Int, anchor: ByteArray, exact: Boolean): String?
fun devgroupCommand(action: Int, group: Long, peer: Long, text: String, pin: String, requestId: Long): Boolean =
nativeDevgroupCommand(action, group, peer, text.toByteArray(Charsets.UTF_8), pin.toByteArray(Charsets.UTF_8), requestId)
private external fun nativeDevgroupCommand(action: Int, group: Long, peer: Long, text: ByteArray, pin: ByteArray, requestId: Long): Boolean
private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean
private external fun nativeSetDebugLevel(category: String, level: String)
private external fun nativeGetDebugCategories(): String?
@@ -0,0 +1,52 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.DevgroupRepository
import kotlinx.coroutines.delay
@Composable
fun DevgroupDialog(channel: String, peer: Long = 0, onDismiss: () -> Unit) {
val state by DevgroupRepository.state.collectAsState()
var name by remember { mutableStateOf("") }
var invite by remember { mutableStateOf("") }
var pin by remember { mutableStateOf("") }
LaunchedEffect(Unit) { while (true) { DevgroupRepository.command(); delay(1000) } }
DisposableEffect(Unit) { onDispose { DevgroupRepository.dismiss() } }
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.devgroup_title)) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (state.member) state.name else stringResource(R.string.devgroup_none))
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.devgroup_name)) })
TextButton(onClick = { DevgroupRepository.command(3, text = name) }) { Text(stringResource(R.string.devgroup_rename)) }
if (peer != 0L) TextButton(onClick = { DevgroupRepository.command(1, channel.toULongOrNull()?.toLong() ?: 0, peer) }) {
Text(stringResource(R.string.devgroup_add, peer.toULong().toString(16)))
}
if (state.pin.isNotEmpty()) Text(stringResource(R.string.devgroup_pin, state.pin))
if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error)
for (entry in state.invitations) {
val label = when (entry.state) {
1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring
4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired
else -> R.string.devgroup_unknown
}
Text(entry.peer + " — " + stringResource(label))
if (!entry.outgoing && entry.state == 1) TextButton(onClick = { invite = entry.id; pin = "" }) {
Text(stringResource(R.string.devgroup_enter_pin))
}
}
if (state.member) TextButton(onClick = { DevgroupRepository.command(4) }) { Text(stringResource(R.string.devgroup_leave)) }
} }, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } })
if (invite.isNotEmpty()) AlertDialog(onDismissRequest = { invite = "" }, title = { Text(stringResource(R.string.devgroup_enter_pin)) },
text = { OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin = it },
label = { Text(stringResource(R.string.devgroup_pin_hint)) }) },
confirmButton = { TextButton(enabled = pin.length == 4, onClick = {
DevgroupRepository.command(2, text = invite, pin = pin); invite = ""; pin = ""
}) { Text(stringResource(android.R.string.ok)) } })
}
@@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import com.utun.chat.data.DevgroupRepository
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -67,11 +69,26 @@ fun MemberListScreen(
) {
var showEditDialog by remember(channel.id) { mutableStateOf(false) }
var editMember by remember(channel.id) { mutableStateOf<ChatMember?>(null) }
var devicesDialog by remember { mutableStateOf(false) }
var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf<String>()) }
val devices by DevgroupRepository.state.collectAsState()
val rows = remember(members, expandedGroups) {
buildList<Pair<ChatMember, Boolean>> {
val seen = mutableSetOf<String>()
for (member in members) {
if (member.devgroup.isEmpty()) add(member to false)
else if (seen.add(member.devgroup)) {
add(member to true)
if (member.devgroup in expandedGroups) members.filter { it.devgroup == member.devgroup }.forEach { add(it to false) }
}
}
}
}
val selection = selectedMember?.takeIf { it.channelId == channel.id }
LaunchedEffect(channel.id) {
LogManager.addLog("DEBUG", "Members", "Member list: channel=${channel.id} actionWidth=36dp statusIconGap=2dp")
viewModel.refreshMembers(channel.id)
while (true) { viewModel.refreshMembers(channel.id); kotlinx.coroutines.delay(2000) }
}
val memberLinks by viewModel.memberLinks.collectAsState()
@@ -98,13 +115,24 @@ fun MemberListScreen(
}
)
TextButton(onClick = { devicesDialog = true }) {
Text(stringResource(R.string.devgroup_title) + if (devices.invitations.any { !it.outgoing && it.state == 1 }) " •" else "")
}
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
items(members, key = { it.nodeId }) { m ->
MemberListItem(
items(rows, key = { if (it.second) "group:" + it.first.devgroup else "node:" + it.first.nodeId }) { (m, header) ->
if (header) {
val online = members.any { it.devgroup == m.devgroup && it.online && !it.isDeleted }
Text((if (m.devgroup in expandedGroups) "[−] " else "[+] ") + m.devgroupName,
color = if (online) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth().clickable {
expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup
}.padding(16.dp))
} else MemberListItem(
member = m,
group = channel.id,
speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null
@@ -150,6 +178,9 @@ fun MemberListScreen(
}
}
if (devicesDialog) DevgroupDialog(channel.id, selection?.nodeId?.takeIf { id -> members.any { it.nodeId == id && !it.isSelf } } ?: 0) {
devicesDialog = false
}
if (radioMuteFailed) {
AlertDialog(
onDismissRequest = viewModel::dismissRadioMuteError,
@@ -673,4 +673,21 @@
<string name="audio_graph_kib">КиБ</string>
<string name="audio_graph_bps">байт/с</string>
<string name="audio_graph_kibps">КиБ/с</string>
<string name="devgroup_title">Мои устройства</string>
<string name="devgroup_none">Нет группы устройств</string>
<string name="devgroup_name">Имя группы устройств</string>
<string name="devgroup_rename">Создать / переименовать</string>
<string name="devgroup_add">Добавить устройство %1$s</string>
<string name="devgroup_pin">PIN: %1$s — введите его на другом устройстве в течение 2 минут.</string>
<string name="devgroup_failed">Операция не выполнена. Проверьте соединение и состояние приглашения.</string>
<string name="devgroup_waiting">Ожидается PIN</string>
<string name="devgroup_checking">Проверка PIN</string>
<string name="devgroup_transferring">Передача ключей</string>
<string name="devgroup_joined">Устройство добавлено</string>
<string name="devgroup_rejected">Отказ</string>
<string name="devgroup_expired">Истёк срок</string>
<string name="devgroup_unknown">Результат неизвестен</string>
<string name="devgroup_enter_pin">Ввести PIN</string>
<string name="devgroup_pin_hint">PIN с экрана приглашающего устройства</string>
<string name="devgroup_leave">Выйти из группы устройств</string>
</resources>
@@ -672,4 +672,21 @@
<string name="audio_graph_kib">KiB</string>
<string name="audio_graph_bps">B/s</string>
<string name="audio_graph_kibps">KiB/s</string>
<string name="devgroup_title">My devices</string>
<string name="devgroup_none">No device group</string>
<string name="devgroup_name">Device group name</string>
<string name="devgroup_rename">Create / rename</string>
<string name="devgroup_add">Add device %1$s</string>
<string name="devgroup_pin">PIN: %1$s — enter it on the other device within 2 minutes.</string>
<string name="devgroup_failed">Operation failed. Check connection and invitation status.</string>
<string name="devgroup_waiting">Waiting for PIN</string>
<string name="devgroup_checking">Checking PIN</string>
<string name="devgroup_transferring">Transferring keys</string>
<string name="devgroup_joined">Joined</string>
<string name="devgroup_rejected">Rejected</string>
<string name="devgroup_expired">Expired</string>
<string name="devgroup_unknown">Result unknown</string>
<string name="devgroup_enter_pin">Enter PIN</string>
<string name="devgroup_pin_hint">PIN shown on the inviting device</string>
<string name="devgroup_leave">Leave device group</string>
</resources>
@@ -6,6 +6,16 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
class MemberRepositoryTest {
@org.junit.Test fun deviceGroupSurvivesTheSharedMemberSnapshot() {
val bytes = ByteArray(MEMBER_WIRE_SIZE)
java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(7)
bytes[164] = 42
"Мои устройства".toByteArray(Charsets.UTF_8).copyInto(bytes, 196)
val member = requireNotNull(parseMemberWire(bytes)).toChatMember()
org.junit.Assert.assertEquals("2a" + "00".repeat(31), member.devgroup)
org.junit.Assert.assertEquals("Мои устройства", member.devgroupName)
org.junit.Assert.assertEquals(7L, member.nodeId)
}
private fun row(node: Long, flags: Int = 0x40, online: Boolean = true, up: Int = 5, rtt: Int = 30): ByteArray =
ByteArray(MEMBER_WIRE_SIZE).also { bytes ->
ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(0, node).putShort(11, rtt.toShort())
@@ -1,3 +1,4 @@
#include "chat/chat_devgroup.h"
/*
* android_jni_bridge.c — JNI bridge implementation
*
@@ -2283,6 +2284,25 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages(
}
/* UTF-8 JSON приходит байтами, чтобы emoji не превращались в JNI modified UTF-8. */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDevgroupCommand(
JNIEnv* env, jobject thiz, jint action, jlong group, jlong peer, jbyteArray text, jbyteArray pin, jlong request_id) {
(void)thiz;
if (!text || !pin || !instance_lite_is_running()) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: core unavailable/invalid request"); return JNI_FALSE;
}
jsize n = (*env)->GetArrayLength(env, text), m = (*env)->GetArrayLength(env, pin);
if (n >= 128 || m >= 5) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: input too long"); return JNI_FALSE; }
struct chat_devgroup_request* req = u_calloc(1, sizeof(*req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup JNI: allocation failed"); return JNI_FALSE; }
req->action = action; req->group = (uint64_t)group; req->peer = (uint64_t)peer; req->request_id = (uint64_t)request_id;
(*env)->GetByteArrayRegion(env, text, 0, n, (jbyte*)req->text);
(*env)->GetByteArrayRegion(env, pin, 0, m, (jbyte*)req->pin);
if ((*env)->ExceptionCheck(env) || memchr(req->text, 0, n) || memchr(req->pin, 0, m)) {
memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: invalid text"); return JNI_FALSE;
}
return bridge_post_request(chat_devgroup_trampoline, req, &req->inst) == 0 ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFeedbackCommand(
JNIEnv* env, jobject thiz, jbyteArray json, jlong request_id) {
(void)thiz;
+2 -1
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
@@ -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)
+8
View File
@@ -86,6 +86,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
else
painter->fillRect(option.rect, option.palette.alternateBase());
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) {
QFont font = painter->font(); font.setBold(true); painter->setFont(font);
painter->setPen(index.data(MemberListModel::OnlineRole).toBool() ? QColor("#4CAF50") : option.palette.color(QPalette::Text));
painter->drawText(option.rect.adjusted(8, 0, -8, 0), Qt::AlignVCenter, index.data().toString());
painter->restore(); return;
}
const int margin = 4;
const int iconSz = MemberListModel::kAvatarSize;
@@ -154,6 +160,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
const QModelIndex& index) {
Q_UNUSED(model);
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false;
if (event->type() == QEvent::MouseMove) {
const auto* mouse = static_cast<QMouseEvent*>(event);
if (m_pressedIndex.isValid() && (!(mouse->buttons() & Qt::LeftButton) ||
@@ -192,6 +199,7 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
const QModelIndex& index) {
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false;
const bool self = index.data(Qt::UserRole + 2).toBool();
if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) {
const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED;
+23 -14
View File
@@ -1,3 +1,5 @@
#include "devgroupdialog.h"
#include <QPushButton>
#include <QCoreApplication>
#include "accountlist.h"
#include "addressprofiletext.h"
@@ -82,33 +84,37 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
m_header->setAlignment(Qt::AlignCenter);
m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);");
layout->addWidget(m_header);
auto* devices = new QPushButton(tr("My devices"), this); layout->addWidget(devices);
watchDevgroupButton(devices);
connect(devices, &QPushButton::clicked, this, [this, devices] { devices->setText(tr("My devices")); showDevgroupDialog(this, m_channelId); });
m_listView->setModel(m_model);
m_grouped = new GroupedMemberModel(m_model, this);
m_listView->setModel(m_grouped);
auto* delegate = new AccountDelegate(m_listView);
m_listView->setItemDelegate(delegate);
connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) {
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0)
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(m_grouped->sourceRow(index.row()))) < 0)
QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"),
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
});
connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row()));
showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->sourceRow(index.row())));
});
connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(index.row());
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
(unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit radioCallRequested(m_channelId, peer);
});
connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(index.row());
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
(unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
@@ -218,7 +224,8 @@ void AccountList::refresh() { m_model->refresh(); }
void AccountList::onMemberClicked(const QModelIndex& index) {
if (!index.isValid()) return;
quint64 nodeId = m_model->nodeIdAt(index.row());
if (m_grouped->toggle(index.row())) { clearMemberDetail(); return; }
quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (nodeId) showMemberDetail(nodeId);
}
@@ -251,8 +258,8 @@ void AccountList::onSnapshotChanged(quint64 nodeId) {
}
if (!m_model->member(m_detailNodeId)) { clearMemberDetail(); return; }
m_detailDirty = true; m_detailHops.clear();
for (int row = 0; row < m_model->rowCount(); row++) if (m_model->nodeIdAt(row) == m_detailNodeId) {
m_listView->setCurrentIndex(m_model->index(row)); break;
for (int row = 0; row < m_grouped->rowCount(); row++) if (m_model->nodeIdAt(m_grouped->sourceRow(row)) == m_detailNodeId) {
m_listView->setCurrentIndex(m_grouped->index(row)); break;
}
updateDetailDisplay();
requestMemberDetail();
@@ -514,14 +521,14 @@ void AccountList::editMember(quint64 nodeId, bool properties) {
}
void AccountList::onMemberDoubleClicked(const QModelIndex& index) {
if (index.isValid()) editMember(m_model->nodeIdAt(index.row()), true);
if (index.isValid()) editMember(m_model->nodeIdAt(m_grouped->sourceRow(index.row())), true);
}
/* Вложенный event loop меню допускает смену канала, снимка и уничтожение панели. */
void AccountList::onMemberContextMenu(const QPoint& pos) {
const QModelIndex index = m_listView->indexAt(pos);
if (!index.isValid()) return;
const quint64 nodeId = m_model->nodeIdAt(index.row());
const quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
const auto member = m_model->member(nodeId);
if (!nodeId || !member) return;
const QString channel = m_channelId;
@@ -530,6 +537,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QPointer<AccountList> alive(this);
QMenu menu;
connect(this, &QObject::destroyed, &menu, &QMenu::close);
QAction* deviceAction = menu.addAction(tr("Add to my devices")); deviceAction->setEnabled(!self);
QAction* pmAction = menu.addAction(QCoreApplication::translate("ChatGui", "Send message"));
pmAction->setEnabled(!self);
QAction* inviteAction = menu.addAction(QCoreApplication::translate("ChatGui", "Invite to group"));
@@ -567,6 +575,7 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
if (chosen == filesAction) { emit filesRequested(channel, nodeId); return; }
if (chosen == pmAction) { emit pmRequested(channel, nodeId, name); return; }
if (chosen == callAction) { emit callRequested(channel, nodeId, name); return; }
if (chosen == deviceAction) { showDevgroupDialog(this, channel, nodeId); return; }
if (chosen == connectAction) {
auto* request = static_cast<chat_connect_node_req*>(u_calloc(1, sizeof(chat_connect_node_req)));
if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; }
+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;
+116
View File
@@ -0,0 +1,116 @@
#include <QCoreApplication>
#include <algorithm>
#include "devgroupdialog.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QDialog>
#include <QVBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QLineEdit>
#include <QListWidget>
#include <QInputDialog>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTimer>
#include <QPointer>
#include <QHash>
extern "C" {
#include "../../../src/chat/chat_devgroup.h"
#include "../../../lib/mem.h"
}
class DevgroupDialog;
static QPointer<DevgroupDialog> active;
static QList<QPointer<QPushButton>> buttons;
static quint64 nextRequest = 0;
static void devgroupResult(const uint8_t* data, int len);
class DevgroupDialog : public QDialog {
public:
static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); }
void refresh() { send(CHAT_DEVGROUP_STATUS); }
DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) {
setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(tr("My devices")); resize(480, 360);
auto* layout = new QVBoxLayout(this);
title = new QLabel(this); layout->addWidget(title);
name = new QLineEdit(this); name->setPlaceholderText(tr("Device group name")); layout->addWidget(name);
auto* rename = new QPushButton(tr("Create / rename"), this); layout->addWidget(rename);
connect(rename, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_RENAME, name->text()); });
if (peer) {
auto* invite = new QPushButton(tr("Add device %1").arg(QString::number(peer, 16)), this); layout->addWidget(invite);
connect(invite, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_INVITE); });
}
status = new QLabel(this); status->setWordWrap(true); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status);
list = new QListWidget(this); layout->addWidget(list);
auto* accept = new QPushButton(tr("Enter PIN for selected invitation"), this); layout->addWidget(accept);
connect(accept, &QPushButton::clicked, this, [this] {
const auto* item = list->currentItem(); if (!item || item->data(Qt::UserRole).toString().isEmpty()) return;
const QString id = item->data(Qt::UserRole).toString(); bool ok = false;
const QString pin = QInputDialog::getText(this, tr("Add device"), tr("PIN shown on the inviting device"), QLineEdit::Normal, {}, &ok);
if (ok) send(CHAT_DEVGROUP_ACCEPT, id, pin);
});
auto* leave = new QPushButton(tr("Leave device group"), this); layout->addWidget(leave);
connect(leave, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_LEAVE); });
auto* timer = new QTimer(this); timer->setInterval(1000);
connect(timer, &QTimer::timeout, this, [this] { if (!gui_bridge_is_uasync_ready()) pending.clear();
else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) send(CHAT_DEVGROUP_STATUS); });
timer->start(); send(CHAT_DEVGROUP_STATUS);
}
void result(const uint8_t* data, int len) {
if (len < 9) return;
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i);
if (!pending.contains(id)) return;
const int action = pending.take(id);
if (!data[8]) { status->setText(tr("Operation failed. Check connection and invitation status.")); return; }
const auto json = QJsonDocument::fromJson(QByteArray(reinterpret_cast<const char*>(data+9), len-9)).object();
if (action == CHAT_DEVGROUP_INVITE) { status->setText(tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString())); return; }
title->setText(json["group"].toString().isEmpty() ? tr("No device group") : json["name"].toString());
const QString selected = list->currentItem() ? list->currentItem()->data(Qt::UserRole).toString() : QString();
list->clear();
const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"),
tr("Rejected"), tr("Expired"), tr("Result unknown")};
for (const auto value : json["invitations"].toArray()) {
const auto invitation = value.toObject(); const int state = invitation["state"].toInt();
auto* item = new QListWidgetItem(invitation["peer"].toString() + " — " + states.value(state), list);
if (!invitation["outgoing"].toInt() && state == 1) {
item->setData(Qt::UserRole, invitation["id"].toString());
if (item->data(Qt::UserRole).toString() == selected) list->setCurrentItem(item);
}
}
}
private:
void send(chat_devgroup_action action, const QString& text = {}, const QString& pin = {}) {
const auto bytes = text.toUtf8(), code = pin.toUtf8();
if (bytes.size() >= 128 || bytes.contains('\0') || code.size() >= 5 || code.contains('\0')) {
status->setText(tr("Invalid name or PIN")); return;
}
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer;
req->action = action; memcpy(req->text, bytes.constData(), bytes.size()); memcpy(req->pin, code.constData(), code.size());
const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
memset(req, 0, sizeof(*req)); u_free(req); status->setText(tr("Core unavailable"));
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return;
}
pending.insert(id, action);
}
QString channel; quint64 peer;
QLabel *title, *status; QLineEdit* name; QListWidget* list;
QHash<quint64, int> pending;
};
static void devgroupResult(const uint8_t* data, int len) {
if (!len) {
for (const auto& button : buttons) if (button) button->setText(QCoreApplication::translate("AccountList", "My devices") + " •");
if (active) active->refresh();
} else if (active) active->result(data, len);
}
void watchDevgroupButton(QPushButton* button) {
buttons.erase(std::remove_if(buttons.begin(), buttons.end(), [](const QPointer<QPushButton>& b) { return b.isNull(); }), buttons.end());
buttons.append(button); gui_bridge_set_devgroup_result_cb(devgroupResult);
}
void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) {
if (active) { active->close(); active = nullptr; }
gui_bridge_set_devgroup_result_cb(devgroupResult);
active = new DevgroupDialog(parent, channel, peer); active->show();
}
+11
View File
@@ -0,0 +1,11 @@
/* Локальное управление devgroup. Все операции передаются в uasync; секреты не читаются GUI. */
#ifndef DEVGROUPDIALOG_H
#define DEVGROUPDIALOG_H
#include <QtGlobal>
#include <QString>
class QWidget;
class QPushButton;
/* Помечать кнопку при изменении приглашений; указатель отслеживается через QPointer. */
void watchDevgroupButton(QPushButton* button);
void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer = 0);
#endif
+53
View File
@@ -318,3 +318,56 @@ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) {
return;
}
}
static QByteArray memberGroup(const MemberListModel* source, int row) {
const auto member = source->member(source->nodeIdAt(row));
if (!member || !member->devgroup_name[0]) return {};
return QByteArray(reinterpret_cast<const char*>(member->devgroup), 32);
}
GroupedMemberModel::GroupedMemberModel(MemberListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) {
connect(source, &QAbstractItemModel::modelReset, this, [this] { rebuild(true); });
connect(source, &QAbstractItemModel::rowsInserted, this, [this] { rebuild(); });
connect(source, &QAbstractItemModel::rowsRemoved, this, [this] { rebuild(); });
connect(source, &QAbstractItemModel::dataChanged, this, [this] { rebuild(); });
rebuild();
}
void GroupedMemberModel::rebuild(bool forceReset) {
QVector<int> rows; QSet<QByteArray> seen;
for (int i = 0; i < m_source->rowCount(); ++i) {
const auto group = memberGroup(m_source, i);
if (group.isEmpty()) { rows.append(i); continue; }
if (seen.contains(group)) continue;
seen.insert(group); rows.append(-i-1);
if (m_expanded.contains(group)) for (int j = 0; j < m_source->rowCount(); ++j)
if (memberGroup(m_source, j) == group) rows.append(j);
}
if (forceReset || rows != m_rows) { beginResetModel(); m_rows = rows; endResetModel(); }
else if (!rows.isEmpty()) emit dataChanged(index(0), index(rows.size()-1));
}
int GroupedMemberModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : m_rows.size(); }
int GroupedMemberModel::sourceRow(int row) const { return row >= 0 && row < m_rows.size() ? m_rows[row] : -1; }
bool GroupedMemberModel::toggle(int row) {
if (row < 0 || row >= m_rows.size() || m_rows[row] >= 0) return false;
const auto group = memberGroup(m_source, -m_rows[row]-1);
if (!m_expanded.remove(group)) m_expanded.insert(group);
rebuild(); return true;
}
QVariant GroupedMemberModel::data(const QModelIndex& idx, int role) const {
if (!idx.isValid() || idx.model() != this || idx.row() < 0 || idx.row() >= m_rows.size()) return {};
const int row = m_rows[idx.row()];
if (role == GroupHeaderRole) return row < 0;
if (row >= 0) {
const auto value = m_source->data(m_source->index(row), role);
return role == Qt::DisplayRole && !memberGroup(m_source, row).isEmpty() ? QVariant(" " + value.toString()) : value;
}
const auto member = m_source->member(m_source->nodeIdAt(-row-1)); if (!member) return {};
const auto group = memberGroup(m_source, -row-1);
if (role == Qt::DisplayRole) return (m_expanded.contains(group) ? QStringLiteral("[−] ") : QStringLiteral("[+] ")) +
QString::fromUtf8(member->devgroup_name);
if (role == MemberListModel::OnlineRole) {
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i)) return true;
return false;
}
return {};
}
+16
View File
@@ -78,4 +78,20 @@ private:
QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели
};
/* Представление поверх снимков: заголовки не имеют node_id, действия доступны только детям. */
class GroupedMemberModel : public QAbstractListModel {
Q_OBJECT
public:
static constexpr int GroupHeaderRole = Qt::UserRole + 7;
explicit GroupedMemberModel(MemberListModel* source, QObject* parent = nullptr);
int rowCount(const QModelIndex& parent = {}) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
int sourceRow(int row) const; // -1 для заголовка/неверной строки
bool toggle(int row); // true — обработан заголовок
private:
void rebuild(bool forceReset = false);
MemberListModel* m_source;
QVector<int> m_rows; // >=0 — строка источника, <0 — заголовок группы строки -value-1
QSet<QByteArray> m_expanded;
};
#endif
@@ -156,6 +156,28 @@ private slots:
QCOMPARE(body(panel).section('\n', 0, 0), QString("DIR(NONE-STRICT) RTT=5ms")); QCOMPARE(requests.size(), previousRequests);
}
void devgroupHeadersAreExpandableAndNeverRepresentADevice() {
MemberListModel source; source.setChannel("123", false);
GroupedMemberModel grouped(&source);
QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest);
auto first = row(7, true), second = row(8, false), other = row(9, false);
for (auto* bytes : {&first, &second}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
auto data = first + second + other;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(data.constData()), data.size());
QCOMPARE(grouped.rowCount(), 2); QCOMPARE(grouped.sourceRow(0), -1);
QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool());
QVERIFY(grouped.index(0).data(MemberListModel::OnlineRole).toBool());
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4);
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8));
source.onMemberRemoved("123", 7);
QVERIFY(!grouped.index(0).data(MemberListModel::OnlineRole).toBool());
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(8));
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2);
}
void flatModelPreservesRowsSnapshotsAndPersistentIndexes() {
MemberListModel model;
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
@@ -427,3 +449,5 @@ private slots:
QTEST_MAIN(MemberDetailTest)
#include "test_member_detail.moc"
void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn) {}
+28
View File
@@ -2858,4 +2858,32 @@ See log:
<translation>PNG preserves the original pixels and transparency.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>My devices</source><translation>My devices</translation></message>
<message><source>Device group name</source><translation>Device group name</translation></message>
<message><source>Create / rename</source><translation>Create / rename</translation></message>
<message><source>Add device %1</source><translation>Add device %1</translation></message>
<message><source>Enter PIN for selected invitation</source><translation>Enter PIN for selected invitation</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN shown on the inviting device</translation></message>
<message><source>Leave device group</source><translation>Leave device group</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Operation failed. Check connection and invitation status.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — enter it on the other device within 2 minutes.</translation></message>
<message><source>No device group</source><translation>No device group</translation></message>
<message><source>Waiting for PIN</source><translation>Waiting for PIN</translation></message>
<message><source>Checking PIN</source><translation>Checking PIN</translation></message>
<message><source>Transferring keys</source><translation>Transferring keys</translation></message>
<message><source>Joined</source><translation>Joined</translation></message>
<message><source>Rejected</source><translation>Rejected</translation></message>
<message><source>Expired</source><translation>Expired</translation></message>
<message><source>Result unknown</source><translation>Result unknown</translation></message>
<message><source>Invalid name or PIN</source><translation>Invalid name or PIN</translation></message>
<message><source>Core unavailable</source><translation>Core unavailable</translation></message>
</context>
<context>
<name>AccountList</name>
<message><source>My devices</source><translation>My devices</translation></message>
<message><source>Add to my devices</source><translation>Add to my devices</translation></message>
</context>
</TS>
+28
View File
@@ -2868,4 +2868,32 @@ See log:
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>My devices</source><translation>Мои устройства</translation></message>
<message><source>Device group name</source><translation>Имя группы устройств</translation></message>
<message><source>Create / rename</source><translation>Создать / переименовать</translation></message>
<message><source>Add device %1</source><translation>Добавить устройство %1</translation></message>
<message><source>Enter PIN for selected invitation</source><translation>Ввести PIN выбранного приглашения</translation></message>
<message><source>Add device</source><translation>Добавление устройства</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN с экрана приглашающего устройства</translation></message>
<message><source>Leave device group</source><translation>Выйти из группы устройств</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Операция не выполнена. Проверьте соединение и состояние приглашения.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — введите его на другом устройстве в течение 2 минут.</translation></message>
<message><source>No device group</source><translation>Нет группы устройств</translation></message>
<message><source>Waiting for PIN</source><translation>Ожидается PIN</translation></message>
<message><source>Checking PIN</source><translation>Проверка PIN</translation></message>
<message><source>Transferring keys</source><translation>Передача ключей</translation></message>
<message><source>Joined</source><translation>Устройство добавлено</translation></message>
<message><source>Rejected</source><translation>Отказ</translation></message>
<message><source>Expired</source><translation>Истёк срок</translation></message>
<message><source>Result unknown</source><translation>Результат неизвестен</translation></message>
<message><source>Invalid name or PIN</source><translation>Неверное имя или PIN</translation></message>
<message><source>Core unavailable</source><translation>Ядро недоступно</translation></message>
</context>
<context>
<name>AccountList</name>
<message><source>My devices</source><translation>Мои устройства</translation></message>
<message><source>Add to my devices</source><translation>Добавить в мои устройства</translation></message>
</context>
</TS>
+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; }