From 631a176944f1f631160eb42b7556551c91187708 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 7 Sep 2026 00:53:41 +0300 Subject: [PATCH] =?UTF-8?q?chat:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20+=20member=20tags=20(verified/?= =?UTF-8?q?deleted)=20+=20ncd=20revive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat_admin: передача канальных privkey другому узлу (encrypted+signed, ETCP_RT_ID_ADMIN_KEY), событие ADMIN_KEY_RECEIVED - member_sync/bridge: tags verified/deleted, is_channel_owner, transfer_admin (android JNI + chatgui memberpropsdialog) - node_conn_direct: оживление timed_out entry — обновление линков в существующем conn (убрать старые, добавить новые) + немедленная очистка не поднявшегося conn без fin_wait — фикс invite/reconnect --- src/Makefile.am | 2 + src/chat/chat_admin.c | 174 ++++++++++++++ src/chat/chat_admin.h | 28 +++ src/chat/chat_core.h | 11 + src/chat/chat_event.h | 1 + src/chat/chat_sync.c | 3 + src/routing_layer/topo_node_sqlite.c | 33 +++ src/routing_layer/topo_node_sqlite.h | 8 + src/transport_layer/etcp_api.h | 1 + src/transport_layer/node_conn_direct.c | 69 +++++- .../java/com/utun/chat/data/ChatRepository.kt | 12 +- .../main/java/com/utun/chat/data/NativeLib.kt | 12 +- .../utun/chat/ui/screens/MemberListScreen.kt | 88 +++++-- .../com/utun/chat/viewmodel/ChatViewModel.kt | 27 ++- .../jni_bridge/android_jni_bridge.c | 221 ++++++++---------- .../jni_bridge/android_jni_bridge.h | 7 +- tools/chatgui/src/mainwindow.cpp | 14 ++ tools/chatgui/src/memberpropsdialog.cpp | 39 ++++ tools/chatgui/src/memberpropsdialog.h | 4 + tools/chatgui/transport/gui_bridge.h | 5 + tools/chatgui/transport/gui_bridge_impl.cpp | 16 ++ 21 files changed, 615 insertions(+), 160 deletions(-) create mode 100644 src/chat/chat_admin.c create mode 100644 src/chat/chat_admin.h diff --git a/src/Makefile.am b/src/Makefile.am index fc5b4e14..70562992 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -79,6 +79,7 @@ utun_CORE_SOURCES = \ chat/chat_profile.c \ chat/chat_status.c \ chat/chat_sync.c \ + chat/chat_admin.c \ chat/member_sync.c \ chat/merkle_sync.c \ chat/invite_link.c \ @@ -161,6 +162,7 @@ libutun_a_SOURCES = \ chat/chat_profile.c \ chat/chat_status.c \ chat/chat_sync.c \ + chat/chat_admin.c \ chat/member_sync.c \ chat/merkle_sync.c \ chat/invite_link.c \ diff --git a/src/chat/chat_admin.c b/src/chat/chat_admin.c new file mode 100644 index 00000000..f4a67118 --- /dev/null +++ b/src/chat/chat_admin.c @@ -0,0 +1,174 @@ +/* + * chat_admin.c — передача канального приватного ключа (прав админа) другому узлу + * + * Владелец канала передаёт оба ключа (ed25519_privkey + x25519_privkey) выбранному + * мемберу через etcp_router с ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT. + * Получатель верифицирует ключи (вывод pubkey == сохранённые pubkey канала) и импортирует. + */ + +#include "chat_admin.h" +#include "chat_core_priv.h" +#include "chat_event.h" + +#include "../utun_instance.h" +#include "../routing_layer/topo_node_sqlite.h" +#include "../routing_layer/etcp_router.h" +#include "../routing_layer/route_crypto.h" +#include "../transport_layer/etcp_api.h" +#include "../transport_layer/etcp.h" +#include "../transport_layer/secure_channel.h" +#include "../../lib/mem.h" +#include "../../lib/ll_queue.h" +#include "../../lib/debug_config.h" + +#include +#include +#include +#include + +#define CA_ID "chat_admin" + +/* ─── ed25519 pubkey из ed25519 privkey (канальные ключи — независимая пара) ─── */ + +static int ed25519_pub_from_priv(const uint8_t priv[32], uint8_t pub[32]) { + EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, priv, 32); + if (!pkey) return -1; + size_t plen = 32; + int ok = EVP_PKEY_get_raw_public_key(pkey, pub, &plen) == 1 && plen == 32; + EVP_PKEY_free(pkey); + return ok ? 0 : -1; +} + +/* ─── приём переданного ключа ─── + * payload: [group_id:8 LE][ed25519_priv:32][x25519_priv:32] + */ +static void admin_key_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { + if (!entry) return; + if (!conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 8 + 32 + 32) { + queue_dgram_free(entry); queue_entry_free(entry); return; + } + struct UTUN_INSTANCE* inst = conn->instance; + if (!inst) { queue_dgram_free(entry); queue_entry_free(entry); return; } + if (!g_cc.initialized || !g_cc.db) { queue_dgram_free(entry); queue_entry_free(entry); return; } + + const uint8_t* d = entry->dgram; + uint64_t from_node; + memcpy(&from_node, d + ROUTER_SVC_SRC_OFF, 8); + + const uint8_t* payload = d + ROUTER_SVC_PAYLOAD_OFF; + uint64_t group_id; memcpy(&group_id, payload, 8); + const uint8_t* ed_priv = payload + 8; + const uint8_t* x_priv = payload + 8 + 32; + + char ch_id[64]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)group_id); + + /* проверить, что ключи соответствуют публичным ключам канала (защита от подлога) */ + uint8_t ch_x_pub[32], ch_ed_pub[32]; + if (topo_node_sqlite_channel_get(g_cc.db, ch_id, NULL, 0, NULL, ch_x_pub, ch_ed_pub, NULL) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — no channel ch=%s from 0x%016llx, drop", + CA_ID, ch_id, (unsigned long long)from_node); + queue_dgram_free(entry); queue_entry_free(entry); return; + } + + uint8_t ed_pub_check[32], x_pub_check[32]; + if (ed25519_pub_from_priv(ed_priv, ed_pub_check) != 0 + || memcmp(ed_pub_check, ch_ed_pub, 32) != 0 + || sc_compute_public_key_from_private(x_priv, x_pub_check) != SC_OK + || memcmp(x_pub_check, ch_x_pub, 32) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — key mismatch for ch=%s from 0x%016llx, drop", + CA_ID, ch_id, (unsigned long long)from_node); + queue_dgram_free(entry); queue_entry_free(entry); return; + } + + if (topo_node_sqlite_channel_set_privs(g_cc.db, ch_id, x_priv, ed_priv) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: recv — set_privs FAILED ch=%s from 0x%016llx", + CA_ID, ch_id, (unsigned long long)from_node); + queue_dgram_free(entry); queue_entry_free(entry); return; + } + + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: received admin key ch=%s from 0x%016llx — imported, now co-owner", + CA_ID, ch_id, (unsigned long long)from_node); + + uint8_t evt[72]; + uint8_t cl = (uint8_t)strlen(ch_id); + evt[0] = cl; memcpy(evt + 1, ch_id, cl); + memcpy(evt + 1 + cl, &from_node, 8); + chat_event_post(CHAT_EVT_ADMIN_KEY_RECEIVED, evt, 1 + cl + 8); + + queue_dgram_free(entry); queue_entry_free(entry); +} + +/* ─── жизненный цикл ─── */ + +int chat_admin_init(struct UTUN_INSTANCE* inst) { + if (!inst) return -1; + if (etcp_router_bind(inst, ETCP_RT_ID_ADMIN_KEY, admin_key_recv_cb) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: etcp_router_bind failed", CA_ID); + return -1; + } + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: initialized", CA_ID); + return 0; +} + +void chat_admin_destroy(struct UTUN_INSTANCE* inst) { + if (!inst) return; + etcp_router_unbind(inst, ETCP_RT_ID_ADMIN_KEY); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: destroyed", CA_ID); +} + +/* ─── передача прав (вызывается из uasync-потока) ─── */ + +void chat_core_transfer_admin(const char* ch_id, uint64_t target_node_id) { + if (!g_cc.initialized || !g_cc.inst || !g_cc.db) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — not initialized", CA_ID); + return; + } + if (!ch_id || !ch_id[0] || target_node_id == 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — invalid args ch=%s target=0x%016llx", + CA_ID, ch_id ? ch_id : "(null)", (unsigned long long)target_node_id); + return; + } + + uint8_t x_priv[32], ed_priv[32]; + if (topo_node_sqlite_channel_get_privs(g_cc.db, ch_id, x_priv, ed_priv) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — no channel privkey ch=%s (not owner?)", CA_ID, ch_id); + return; + } + + uint64_t group_id = strtoull(ch_id, NULL, 10); + if (group_id == 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — invalid ch_id=%s", CA_ID, ch_id); + return; + } + + uint8_t payload[8 + 32 + 32]; + memcpy(payload, &group_id, 8); + memcpy(payload + 8, ed_priv, 32); + memcpy(payload + 8 + 32, x_priv, 32); + + struct ll_entry* entry = queue_entry_new(0); + if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — queue_entry_new failed", CA_ID); return; } + entry->dgram = u_malloc(1 + sizeof(payload)); + if (!entry->dgram) { queue_entry_free(entry); return; } + entry->dgram[0] = ETCP_RT_ID_ADMIN_KEY; + memcpy(entry->dgram + 1, payload, sizeof(payload)); + entry->len = 1 + (uint16_t)sizeof(payload); + + int rc = etcp_route_send(g_cc.inst, group_id, target_node_id, entry, 1, + ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); + if (rc != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: transfer — etcp_route_send FAILED rc=%d ch=%s → 0x%016llx", + CA_ID, rc, ch_id, (unsigned long long)target_node_id); + queue_dgram_free(entry); queue_entry_free(entry); + return; + } + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: admin key sent ch=%s → 0x%016llx (encrypted+signed)", + CA_ID, ch_id, (unsigned long long)target_node_id); +} + +void chat_core_transfer_admin_trampoline(void* arg) { + struct chat_transfer_admin_req* req = (struct chat_transfer_admin_req*)arg; + if (!req) return; + chat_core_transfer_admin(req->ch_id, req->target_node_id); + u_free(req); +} diff --git a/src/chat/chat_admin.h b/src/chat/chat_admin.h new file mode 100644 index 00000000..e377ff09 --- /dev/null +++ b/src/chat/chat_admin.h @@ -0,0 +1,28 @@ +/* + * chat_admin.h — передача канального приватного ключа (прав админа) другому узлу + * + * Владелец канала (имеющий ed25519_privkey + x25519_privkey) может передать оба ключа + * выбранному мемберу. Передача идёт через сервисный пакет etcp_router с + * ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT: X25519 ECDH + AES-128-CCM и Ed25519-подпись. + * Получатель автоматически импортирует ключи и становится совладельцем канала. + */ + +#ifndef CHAT_ADMIN_H +#define CHAT_ADMIN_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct UTUN_INSTANCE; + +/* Зарегистрировать/снять обработчик сервиса ETCP_RT_ID_ADMIN_KEY. */ +int chat_admin_init(struct UTUN_INSTANCE* inst); +void chat_admin_destroy(struct UTUN_INSTANCE* inst); + +#ifdef __cplusplus +} +#endif +#endif /* CHAT_ADMIN_H */ diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 6e052b80..f1286712 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -182,4 +182,15 @@ struct chat_member_tags_req { }; void chat_member_tags_commit_trampoline(void* arg); +/* ── Передача прав админа (владелец → другому узлу) ── */ + +/** Передать оба канальных privkey выбранному узлу (encrypted+signed через etcp_router). */ +void chat_core_transfer_admin(const char* ch_id, uint64_t target_node_id); +void chat_core_transfer_admin_trampoline(void* arg); + +struct chat_transfer_admin_req { + char ch_id[64]; + uint64_t target_node_id; +}; + #endif /* CHAT_CORE_H */ diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 84b15740..d99d70a7 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -46,6 +46,7 @@ extern "C" { #define CHAT_EVT_MEMBER_REMOVED 25 /* [ch_id_len:1][ch_id:var][node_id:8] — мембер удалён из канала */ #define CHAT_EVT_BGP_INFO 26 /* текст: "rtt:XXms hop:X via bgp: [XXXX] name1 [XXXX] name2" */ #define CHAT_EVT_CONNECTING_NODES 27 /* [ch_id_len:1][ch_id:var][count:2][node_id:8]* */ +#define CHAT_EVT_ADMIN_KEY_RECEIVED 28 /* [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ (стал совладельцем) */ typedef void (*chat_event_handler_fn)(int type, const uint8_t* data, int len); diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index d1c36e55..33befb68 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -4,6 +4,7 @@ #include "chat_member.h" #include "chat_setting.h" #include "chat_event.h" +#include "chat_admin.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" #include "../routing_layer/topo_group_invite.h" @@ -621,6 +622,7 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { cs->join_timer = NULL; member_sync_init(inst); + chat_admin_init(inst); DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: initialized", CS_ID); return 0; @@ -634,6 +636,7 @@ void chat_sync_destroy(struct UTUN_INSTANCE* inst) { if (cs->invite_send_timer) { uasync_cancel_timeout(inst->ua, cs->invite_send_timer); cs->invite_send_timer = NULL; } if (cs->sync_scheduled) { cs->sync_scheduled = 0; cs_flush_sync(cs); } member_sync_destroy(inst); + chat_admin_destroy(inst); cs->initialized = 0; g_cs = NULL; etcp_unbind(inst, ETCP_RT_ID_CHAT_SYNC); diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index b2f1c275..923e3afc 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/src/routing_layer/topo_node_sqlite.c @@ -619,6 +619,39 @@ int topo_node_sqlite_channel_get_priv(sqlite3* db, const char* channel_id, return 0; } +int topo_node_sqlite_channel_get_privs(sqlite3* db, const char* channel_id, + uint8_t* x25519_priv_out, uint8_t* ed25519_priv_out) { + if (!db || !channel_id || !x25519_priv_out || !ed25519_priv_out) return -1; + sqlite3_stmt* stmt = NULL; + if (sqlite3_prepare_v2(db, + "SELECT x25519_privkey, ed25519_privkey FROM channels WHERE channel_id=?", -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, channel_id, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); return -1; } + const uint8_t* x = (const uint8_t*)sqlite3_column_blob(stmt, 0); + const uint8_t* e = (const uint8_t*)sqlite3_column_blob(stmt, 1); + if (!x || sqlite3_column_bytes(stmt, 0) < 32 || !e || sqlite3_column_bytes(stmt, 1) < 32) { + sqlite3_finalize(stmt); return -1; + } + memcpy(x25519_priv_out, x, 32); + memcpy(ed25519_priv_out, e, 32); + sqlite3_finalize(stmt); + return 0; +} + +int topo_node_sqlite_channel_set_privs(sqlite3* db, const char* channel_id, + const uint8_t* x25519_priv, const uint8_t* ed25519_priv) { + if (!db || !channel_id || !x25519_priv || !ed25519_priv) return -1; + sqlite3_stmt* stmt = NULL; + if (sqlite3_prepare_v2(db, + "UPDATE channels SET x25519_privkey=?, ed25519_privkey=? WHERE channel_id=?", -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_blob(stmt, 1, x25519_priv, 32, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 2, ed25519_priv, 32, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, channel_id, -1, SQLITE_STATIC); + int rc = sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; + sqlite3_finalize(stmt); + return rc; +} + int topo_node_sqlite_channel_peers_all(sqlite3* db, const char* channel_id, uint8_t* buf, size_t buf_sz, size_t* out_len) { if (!db || !channel_id || !buf || !out_len) return -1; diff --git a/src/routing_layer/topo_node_sqlite.h b/src/routing_layer/topo_node_sqlite.h index 1c278a52..f2da6114 100644 --- a/src/routing_layer/topo_node_sqlite.h +++ b/src/routing_layer/topo_node_sqlite.h @@ -70,6 +70,14 @@ int topo_node_sqlite_channel_get(sqlite3* db, const char* channel_id, int topo_node_sqlite_channel_get_priv(sqlite3* db, const char* channel_id, uint8_t* ed25519_priv_out); +/* Прочитать оба канальных privkey (x25519 + ed25519). Возвращает 0 если оба есть. */ +int topo_node_sqlite_channel_get_privs(sqlite3* db, const char* channel_id, + uint8_t* x25519_priv_out, uint8_t* ed25519_priv_out); + +/* Записать оба канальных privkey (импорт прав админа). */ +int topo_node_sqlite_channel_set_privs(sqlite3* db, const char* channel_id, + const uint8_t* x25519_priv, const uint8_t* ed25519_priv); + int topo_node_sqlite_channel_peers_all(sqlite3* db, const char* channel_id, uint8_t* buf, size_t buf_sz, size_t* out_len); diff --git a/src/transport_layer/etcp_api.h b/src/transport_layer/etcp_api.h index f8c0aed3..5569c4b0 100644 --- a/src/transport_layer/etcp_api.h +++ b/src/transport_layer/etcp_api.h @@ -46,6 +46,7 @@ extern "C" { #define ETCP_RT_ID_CONN_MGR 0x11 // Connection Manager — management connections #define ETCP_RT_ID_NCD_CONTROL 0x12 // node_conn_direct — CLOSE / KEEP_ALIVE control +#define ETCP_RT_ID_ADMIN_KEY 0x32 // chat_admin — передача канального приватного ключа (encrypted+signed) // Connection status events (instance-level callback) #define ETCP_CONN_STATUS_NEW 0 // соединение создано diff --git a/src/transport_layer/node_conn_direct.c b/src/transport_layer/node_conn_direct.c index bec3262c..b221bf73 100644 --- a/src/transport_layer/node_conn_direct.c +++ b/src/transport_layer/node_conn_direct.c @@ -477,6 +477,37 @@ static void ncd_init_control_binding(struct UTUN_INSTANCE* inst) { DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] bound ETCP_RT_ID_NCD_CONTROL handler"); } +/* ═══════════ оживление «зависшего» (timed_out) entry ═══════════ */ +/* + * Entry не поднялся за connect-таймер (timed_out) и «завис»: conn живёт в + * реестре, но линки мертвы. При новом open переиспользуем conn и обновляем + * линки: убираем старые, добавляем новые по свежим адресам, заново заводим + * таймер подключения. Если линк всё же успел подняться — просто отдаём UP. + */ +static void ncd_revive_entry(struct ncd_entry* entry, struct UTUN_INSTANCE* inst, + struct TOPO_NODE* ni, struct ETCP_SOCKET* specific_sock) { + if (!entry) return; + if (entry->conn && entry->conn->links_up > 0) { + entry->timed_out = 0; + ncd_event_dispatch(entry, NCD_EVENT_UP); + return; + } + struct ETCP_LINK* l = entry->conn ? entry->conn->links : NULL; + while (l) { struct ETCP_LINK* nx = l->next; etcp_link_close(l); l = nx; } + if (!ni) { + /* оставляем timed_out=1 — следующий open снова попробует revive со свежим lookup */ + DEBUG_WARN(DEBUG_CATEGORY_NCD, "[ncd] revive: node=0x%016llx no node info — cannot recreate links", + (unsigned long long)entry->node_id); + return; + } + entry->timed_out = 0; + ncd_create_links(entry, ni, specific_sock); + entry->connect_timer = uasync_set_timeout(inst->ua, (int)inst->etcp_connect_timeout_tb, + entry, ncd_connect_timeout_cb, "ncd_connect"); + DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] revive: node=0x%016llx links recreated, connect timer restarted", + (unsigned long long)entry->node_id); +} + /* ═══════════ API ═══════════ */ /* @@ -519,6 +550,11 @@ int node_conn_direct_open(struct UTUN_INSTANCE* inst, uint64_t node_id, if (entry->conn && entry->conn->state == 1 && entry->up) { uasync_call_soon(inst->ua, h, ncd_deliver_up_cb); DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open REUSED (ready) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); + } else if (entry->timed_out) { + struct TOPO_NODE* ni = ncd_lookup_node(inst, node_id); + ncd_revive_entry(entry, inst, ni, specific_sock); + if (ni) topo_node_registry_unref(inst->topo_groups, ni->node_id); + DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open REUSED (revived) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); } else { DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open REUSED (pending) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); } @@ -673,6 +709,9 @@ int node_conn_direct_open_node(struct UTUN_INSTANCE* inst, uint64_t node_id, if (entry->conn && entry->conn->state == 1 && entry->up) { uasync_call_soon(inst->ua, h, ncd_deliver_up_cb); DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open_node REUSED (ready) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); + } else if (entry->timed_out) { + ncd_revive_entry(entry, inst, ni, specific_sock); + DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open_node REUSED (revived) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); } else { DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] open_node REUSED (pending) node=0x%016llx handles=%d", (unsigned long long)node_id, entry->handle_count); } @@ -824,7 +863,7 @@ void node_conn_direct_close(struct NODE_CONN_DIRECT* h) { } h->cb = NULL; h->cb_arg = NULL; if (entry->connect_timer) { uasync_cancel_timeout(entry->ua, entry->connect_timer); entry->connect_timer = NULL; } - if (conn && conn->state != 2) { + if (conn && conn->state != 2 && !entry->timed_out) { /* устанавливаем fin_wait, отправляем CLOSE, ставим таймер */ conn->fin_wait = 1; conn->fin_wait_clear_cb = ncd_fin_wait_cancelled; @@ -838,17 +877,25 @@ void node_conn_direct_close(struct NODE_CONN_DIRECT* h) { } entry->fin_wait_timer = uasync_set_timeout(entry->ua, NCD_FIN_WAIT_TIMEOUT_TB, entry, ncd_fin_wait_timeout_cb, "ncd_fin_wait"); DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] fin_wait started node=0x%016llx", (unsigned long long)node_id); - } else { - if (conn && conn->state != 2) { - etcp_conn_remove_cbk(conn, ncd_init_cb, entry); - etcp_conn_remove_cbk(conn, ncd_up_cb, entry); - etcp_conn_remove_cbk(conn, ncd_down_cb, entry); - etcp_connection_close(conn); - ncd_registry_remove(conn->instance, entry); - u_free(entry); - } else { - DEBUG_WARN(DEBUG_CATEGORY_NCD, "[ncd] close: no conn for entry, leaking entry=%p", entry); + } else if (conn && conn->state != 2) { + /* conn никогда не поднялся (timed_out) — graceful CLOSE не нужен, чистим сразу */ + conn->fin_wait = 0; + conn->fin_wait_clear_cb = NULL; conn->fin_wait_clear_arg = NULL; + etcp_conn_remove_cbk(conn, ncd_init_cb, entry); + etcp_conn_remove_cbk(conn, ncd_up_cb, entry); + etcp_conn_remove_cbk(conn, ncd_down_cb, entry); + if (conn->conn_queue && conn->conn_queue_entry) { + queue_remove_data(conn->conn_queue, conn->conn_queue_entry); + queue_entry_free(conn->conn_queue_entry); + conn->conn_queue_entry = NULL; conn->conn_queue = NULL; } + uasync_call_soon(entry->ua, conn, ncd_deferred_close_conn); + ncd_registry_remove(conn->instance, entry); + u_free(entry); + DEBUG_INFO(DEBUG_CATEGORY_NCD, "[ncd] close: conn never came up, immediate cleanup node=0x%016llx", + (unsigned long long)node_id); + } else { + DEBUG_WARN(DEBUG_CATEGORY_NCD, "[ncd] close: no conn for entry, leaking entry=%p", entry); } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 93e80ca1..e9b5495e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -63,6 +63,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false, val isModer: Boolean = false, val isStorage: Boolean = false, + val isVerified: Boolean = false, val isDeleted: Boolean = false, val nodeType: Int = 0, val rtt: Int = 0, val isSelf: Boolean = false, val directUp: Boolean = false, val indirectUp: Boolean = false, @@ -358,6 +359,8 @@ class ChatRepository { isSuper = obj.optInt("sp", 0) != 0, isModer = obj.optInt("md", 0) != 0, isStorage = obj.optInt("st", 0) != 0, + isVerified = obj.optInt("vd", 0) != 0, + isDeleted = obj.optInt("dl", 0) != 0, nodeType = obj.optInt("tp", 0), rtt = obj.optInt("rt", 0), isSelf = obj.optInt("is", 0) != 0 @@ -401,8 +404,13 @@ class ChatRepository { fun setMemberNick(channelId: String, nodeId: Long, nick: String) = NativeLib.setMemberNick(channelId, nodeId, nick) - fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) = - NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage) + fun isChannelOwner(channelId: String): Boolean = NativeLib.isChannelOwner(channelId) + + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, + verified: Boolean, deleted: Boolean) = + NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage, verified, deleted) + + fun transferAdmin(channelId: String, nodeId: Long) = NativeLib.transferAdmin(channelId, nodeId) fun getMemberLinks(nodeId: Long): MemberLinks { try { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 2be57969..c58bf26c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -87,9 +87,12 @@ object NativeLib { fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]" fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } - fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) { - nativeSetMemberFlags(channelId, nodeId, admin, supernode, moder, storage) + fun isChannelOwner(channelId: String): Boolean = nativeIsChannelOwner(channelId) + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, + verified: Boolean, deleted: Boolean) { + nativeSetMemberFlags(channelId, nodeId, admin, supernode, moder, storage, verified, deleted) } + fun transferAdmin(channelId: String, nodeId: Long) { nativeTransferAdmin(channelId, nodeId) } fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}" /* ── Local sockets (for auto-mode display) ── */ @@ -153,7 +156,10 @@ object NativeLib { private external fun nativeGetChannelMembers(channelId: String): String? private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String) - private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) + private external fun nativeIsChannelOwner(channelId: String): Boolean + private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, + verified: Boolean, deleted: Boolean) + private external fun nativeTransferAdmin(channelId: String, nodeId: Long) private external fun nativeGetMemberLinks(nodeId: Long): String? /* ── Local sockets JNI ── */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 965997df..7edbf23f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -52,6 +52,7 @@ fun MemberListScreen( val memberLinks by viewModel.memberLinks.collectAsState() val localSockets by viewModel.localSockets.collectAsState() + val channelOwner by viewModel.channelOwner.collectAsState() LaunchedEffect(selectedMember?.nodeId) { val nodeId = selectedMember?.nodeId @@ -61,7 +62,7 @@ fun MemberListScreen( Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { TopAppBar( - title = { Text("Members (${members.size})") }, + title = { Text(if (channelOwner) "Members 🔑 (${members.size})" else "Members (${members.size})") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } @@ -106,11 +107,17 @@ fun MemberListScreen( if (showEditDialog && editMember != null) { MemberEditDialog( member = editMember!!, + isOwner = channelOwner, onDismiss = { showEditDialog = false }, - onSave = { nick, admin, supernode, moder, storage -> + onSave = { nick, admin, supernode, moder, storage, verified, deleted -> if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick) - if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || moder != editMember!!.isModer || storage != editMember!!.isStorage) - viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, moder, storage) + if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || moder != editMember!!.isModer || + storage != editMember!!.isStorage || verified != editMember!!.isVerified || deleted != editMember!!.isDeleted) + viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, moder, storage, verified, deleted) + showEditDialog = false + }, + onTransferAdmin = { + viewModel.transferAdmin(channel.id, editMember!!.nodeId) showEditDialog = false } ) @@ -174,7 +181,10 @@ private fun MemberListItem( val parts = mutableListOf() if (member.isAdmin) parts.add("Admin") if (member.isSuper) parts.add("Super") + if (member.isModer) parts.add("Moder") if (member.isStorage) parts.add("Storage") + if (member.isVerified) parts.add("Verified") + if (member.isDeleted) parts.add("Deleted") if (member.isSelf) parts.add("self") else if (member.connecting) parts.add("connecting…") else if (member.directUp) parts.add("direct↑") @@ -376,14 +386,20 @@ private fun MemberDetailCard( @Composable private fun MemberEditDialog( member: ChatMember, + isOwner: Boolean, onDismiss: () -> Unit, - onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) -> Unit + onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, + verified: Boolean, deleted: Boolean) -> Unit, + onTransferAdmin: () -> Unit ) { var nick by remember { mutableStateOf(member.name) } var admin by remember { mutableStateOf(member.isAdmin) } var supernode by remember { mutableStateOf(member.isSuper) } var moder by remember { mutableStateOf(member.isModer) } var storage by remember { mutableStateOf(member.isStorage) } + var verified by remember { mutableStateOf(member.isVerified) } + var deleted by remember { mutableStateOf(member.isDeleted) } + var showTransferConfirm by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, @@ -400,28 +416,72 @@ private fun MemberEditDialog( ) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = admin, onCheckedChange = { admin = it }) - Text("Admin", modifier = Modifier.clickable { admin = !admin }) + Checkbox(checked = admin, onCheckedChange = { admin = it }, enabled = isOwner) + Text("Admin", modifier = Modifier.clickable(enabled = isOwner) { admin = !admin }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = supernode, onCheckedChange = { supernode = it }, enabled = isOwner) + Text("Supernode", modifier = Modifier.clickable(enabled = isOwner) { supernode = !supernode }) } Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = supernode, onCheckedChange = { supernode = it }) - Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) + Checkbox(checked = moder, onCheckedChange = { moder = it }, enabled = isOwner) + Text("Moderator", modifier = Modifier.clickable(enabled = isOwner) { moder = !moder }) } Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = moder, onCheckedChange = { moder = it }) - Text("Moderator", modifier = Modifier.clickable { moder = !moder }) + Checkbox(checked = storage, onCheckedChange = { storage = it }, enabled = isOwner) + Text("Storage", modifier = Modifier.clickable(enabled = isOwner) { storage = !storage }) } Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = storage, onCheckedChange = { storage = it }) - Text("Storage", modifier = Modifier.clickable { storage = !storage }) + Checkbox(checked = verified, onCheckedChange = { verified = it }, enabled = isOwner) + Text("Verified", modifier = Modifier.clickable(enabled = isOwner) { verified = !verified }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = deleted, onCheckedChange = { deleted = it }, enabled = isOwner) + Text("Deleted", modifier = Modifier.clickable(enabled = isOwner) { deleted = !deleted }) + } + if (isOwner && !member.isSelf) { + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { showTransferConfirm = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Transfer admin rights") + } + } else if (!isOwner) { + Spacer(Modifier.height(4.dp)) + Text( + "Admin/Super require channel private key", + fontSize = 11.sp, + color = Color.Gray + ) } } }, confirmButton = { - TextButton(onClick = { onSave(nick, admin, supernode, moder, storage) }) { Text("Save") } + TextButton(onClick = { onSave(nick, admin, supernode, moder, storage, verified, deleted) }) { Text("Save") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) + + if (showTransferConfirm) { + AlertDialog( + onDismissRequest = { showTransferConfirm = false }, + title = { Text("Transfer admin rights") }, + text = { + Text( + "Send the channel private key to \"${member.name}\"?\n\n" + + "The key is transferred encrypted and signed. The recipient will become a co-owner " + + "and will be able to manage members." + ) + }, + confirmButton = { + TextButton(onClick = { showTransferConfirm = false; onTransferAdmin() }) { Text("Send") } + }, + dismissButton = { + TextButton(onClick = { showTransferConfirm = false }) { Text("Cancel") } + } + ) + } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index cfda8776..5a9c18f7 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -53,6 +53,9 @@ class ChatViewModel : ViewModel() { private val _members = MutableStateFlow>(emptyList()) val members: StateFlow> = _members + private val _channelOwner = MutableStateFlow(false) + val channelOwner: StateFlow = _channelOwner + private val _selectedMember = MutableStateFlow(null) val selectedMember: StateFlow = _selectedMember @@ -322,6 +325,16 @@ class ChatViewModel : ViewModel() { _members.value = _members.value.map { it.copy(connecting = it.nodeId in set) } } } + 28 -> { /* ADMIN_KEY_RECEIVED: [ch_id_len:1][ch_id:var][from_node_id:8] */ + if (data == null || data.size < 10) return + val cl = data[0].toInt() and 0xFF + if (data.size < 1 + cl + 8) return + val chId = String(data, 1, cl) + val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val from = buf.getLong() + LogManager.addLog("INFO", "VM", "admin key received ch=$chId from=0x%016x — now co-owner".format(from)) + if (_currentChannel.value?.id == chId) refreshMembers(chId) + } } } @@ -367,7 +380,8 @@ class ChatViewModel : ViewModel() { val statuses = _nodeStatuses.value val connecting = _connecting.value[chId] ?: emptySet() viewModelScope.launch { - val raw = withContext(Dispatchers.IO) { r.getMembers(chId) } + val (raw, owner) = withContext(Dispatchers.IO) { r.getMembers(chId) to r.isChannelOwner(chId) } + _channelOwner.value = owner _members.value = raw.map { m -> val ns = statuses[m.nodeId] val base = if (ns != null) m.copy( @@ -404,12 +418,18 @@ class ChatViewModel : ViewModel() { refreshMembers(channelId) } - fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) { + fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, + verified: Boolean, deleted: Boolean) { val r = repo ?: return - r.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage) + r.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage, verified, deleted) refreshMembers(channelId) } + fun transferAdmin(channelId: String, nodeId: Long) { + val r = repo ?: return + r.transferAdmin(channelId, nodeId) + } + fun refreshMemberLinks(nodeId: Long) { if (nodeId == 0L) { _memberLinks.value = MemberLinks(); return } val r = repo ?: return @@ -615,6 +635,7 @@ class ChatViewModel : ViewModel() { _currentChannel.value = null _generatingKeys.value = false _localSockets.value = emptyList() + _channelOwner.value = false } class Factory : ViewModelProvider.Factory { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index bcddb5ff..085ab3da 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -750,7 +750,7 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { int node_rtt = sqlite3_column_int(st, 6); int storage_flag = sqlite3_column_int(st, 7); - int is_admin = 0, is_super = 0, is_moder = 0; + int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0; if (adm_tags) { char buf[256]; if (json_flat_get(adm_tags, "supernode", buf, sizeof(buf)) == 0 && strcmp(buf, "yes") == 0) is_super = 1; @@ -762,6 +762,8 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { const char* last = buf; for (const char* p = buf; *p; p++) if (*p == 'e' || *p == 'd') last = p; if (*last == 'e') is_moder = 1; } + if (json_flat_get(adm_tags, "verified", buf, sizeof(buf)) == 0 && strcmp(buf, "yes") == 0) is_verified = 1; + if (json_flat_get(adm_tags, "deleted", buf, sizeof(buf)) == 0 && strcmp(buf, "yes") == 0) is_deleted = 1; } char* esc_name = json_escape_alloc(local_nick && local_nick[0] ? local_nick : @@ -771,19 +773,19 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { size_t needed = snprintf(NULL, 0, "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," - "\"tp\":%d,\"rt\":%d,\"is\":%d}", + "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}", sep, esc_name, (unsigned long long)node_id, online, is_admin, is_super, is_moder, storage_flag, - node_type, node_rtt, (node_id == my_id) ? 1 : 0); + is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) pos += (size_t)snprintf(json + pos, cap - pos, - "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"st\":%d," - "\"tp\":%d,\"rt\":%d,\"is\":%d}", + "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," + "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}", sep, esc_name, (unsigned long long)node_id, online, - is_admin, is_super, storage_flag, - node_type, node_rtt, (node_id == my_id) ? 1 : 0); + is_admin, is_super, is_moder, storage_flag, + is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); u_free(esc_name); } sqlite3_finalize(st); @@ -884,126 +886,59 @@ void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const channel_id, (unsigned long long)node_id, nick ? nick : "(null)"); } -void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, - int admin, int supernode, int moder, int storage_flag) { - sqlite3* db = chat_core_get_db(); - if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return; - - char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl)); - - /* read current adm_tags */ - char cur_tags[512] = ""; - sqlite3_stmt* vst = NULL; - char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags FROM \"%s\" WHERE node_id=?", peers_tbl); - if (sqlite3_prepare_v2(db, sql, -1, &vst, NULL) == SQLITE_OK) { - sqlite3_bind_int64(vst, 1, (sqlite3_int64)node_id); - if (sqlite3_step(vst) == SQLITE_ROW) { - const char* tags = (const char*)sqlite3_column_text(vst, 0); - if (tags) snprintf(cur_tags, sizeof(cur_tags), "%s", tags); - } - sqlite3_finalize(vst); - } - - /* parse current ver */ - int cur_ver = 0; - char buf[64]; if (json_flat_get(cur_tags, "ver", buf, sizeof(buf)) == 0) cur_ver = atoi(buf); - int new_ver = cur_ver + 1; - - /* build JSON — supernode/storage: direct; admin/moder: append-only from existing raw */ - char json[4096]; int off = 0; -#define SETMF_ADVANCE(n) do { if (off < (int)sizeof(json) - 10) off += (int)(n); else off = (int)sizeof(json); } while(0) - SETMF_ADVANCE(snprintf(json, sizeof(json), "{\"ver\":\"%d\"", new_ver)); - - if (json_flat_get(cur_tags, "supernode", buf, sizeof(buf)) == 0) - snprintf(buf, sizeof(buf), "%s", buf); else buf[0] = '\0'; - const char* sn_val = supernode ? "yes" : (buf[0] ? buf : NULL); - if (sn_val) SETMF_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, ",\"supernode\":\"%s\"", sn_val)); - - /* admin: append-only */ - { - char raw[256] = ""; - if (json_flat_get(cur_tags, "admin", buf, sizeof(buf)) == 0) snprintf(raw, sizeof(raw), "%s", buf); - char last = 0; for (const char* p = raw; *p; p++) if (*p == 'e' || *p == 'd') last = *p; - if ((admin && last != 'e') || (!admin && last == 'e')) { - uint64_t ts = (uint64_t)ntp_time_get_seconds(chat_core_get_inst()); - size_t ol = strlen(raw); - if (ol < sizeof(raw) - 30) snprintf(raw + ol, sizeof(raw) - ol, "%c%llu", admin ? 'e' : 'd', (unsigned long long)ts); - } - if (raw[0]) SETMF_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, ",\"admin\":\"%s\"", raw)); - } - - /* moder: append-only */ - { - char raw[256] = ""; - if (json_flat_get(cur_tags, "moder", buf, sizeof(buf)) == 0) snprintf(raw, sizeof(raw), "%s", buf); - char last = 0; for (const char* p = raw; *p; p++) if (*p == 'e' || *p == 'd') last = *p; - if ((moder && last != 'e') || (!moder && last == 'e')) { - uint64_t ts = (uint64_t)ntp_time_get_seconds(chat_core_get_inst()); - size_t ol = strlen(raw); - if (ol < sizeof(raw) - 30) snprintf(raw + ol, sizeof(raw) - ol, "%c%llu", moder ? 'e' : 'd', (unsigned long long)ts); - } - if (raw[0]) SETMF_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, ",\"moder\":\"%s\"", raw)); - } - - /* storage */ - if (json_flat_get(cur_tags, "storage", buf, sizeof(buf)) == 0) - snprintf(buf, sizeof(buf), "%s", buf); else buf[0] = '\0'; - const char* st_val = storage_flag ? "yes" : (buf[0] ? buf : NULL); - if (st_val) SETMF_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, ",\"storage\":\"%s\"", st_val)); - - SETMF_ADVANCE(snprintf(json + off, sizeof(json) - (size_t)off, "}")); -#undef SETMF_ADVANCE - - /* get channel ed25519 private key for signing */ - uint8_t ch_ed_priv[32] = {0}; - int have_key = 0; - sqlite3_stmt* ks = NULL; - if (sqlite3_prepare_v2(db, - "SELECT ed25519_privkey FROM channels WHERE channel_id=?", - -1, &ks, NULL) == SQLITE_OK) { - sqlite3_bind_text(ks, 1, channel_id, -1, SQLITE_STATIC); - if (sqlite3_step(ks) == SQLITE_ROW) { - const uint8_t* kp = (const uint8_t*)sqlite3_column_blob(ks, 0); - if (kp && sqlite3_column_bytes(ks, 0) >= 32) { memcpy(ch_ed_priv, kp, 32); have_key = 1; } - } - sqlite3_finalize(ks); - } +struct member_tags_edit_req { + char ch_id[64]; + uint64_t node_id; + int admin, supernode, moder, storage, verified, deleted; +}; - uint8_t sig[64] = {0}; - if (have_key) { - uint8_t msg[512]; size_t mlen = (size_t)off; - memcpy(msg, json, mlen); - memcpy(msg + mlen, &node_id, 8); mlen += 8; - EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, ch_ed_priv, 32); - if (pkey) { - EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); - if (mdctx) { - size_t siglen = 64; - if (EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey) == 1) - EVP_DigestSign(mdctx, sig, &siglen, msg, mlen); - EVP_MD_CTX_free(mdctx); - } - EVP_PKEY_free(pkey); - } +static void member_tags_edit_trampoline(void* arg) { + struct member_tags_edit_req* req = (struct member_tags_edit_req*)arg; + if (!req) return; + struct chat_member_tags* t = chat_member_tags_load(req->ch_id, req->node_id); + if (!t) { + bridge_log(BLEV_ERROR, "set_member_flags: tags_load failed ch=%s nid=0x%016llx", + req->ch_id, (unsigned long long)req->node_id); + u_free(arg); + return; } + chat_member_tags_set(t, "admin", req->admin ? "yes" : "no"); + chat_member_tags_set(t, "moder", req->moder ? "yes" : "no"); + chat_member_tags_set(t, "supernode", req->supernode ? "yes" : "no"); + chat_member_tags_set(t, "storage", req->storage ? "yes" : "no"); + chat_member_tags_set(t, "verified", req->verified ? "yes" : "no"); + chat_member_tags_set(t, "deleted", req->deleted ? "yes" : "no"); + int rc = chat_member_tags_commit(t); + bridge_log(rc == 0 ? BLEV_INFO : BLEV_ERROR, + "set_member_flags ch=%s nid=0x%016llx admin=%d super=%d moder=%d storage=%d verified=%d deleted=%d rc=%d", + req->ch_id, (unsigned long long)req->node_id, req->admin, req->supernode, + req->moder, req->storage, req->verified, req->deleted, rc); + chat_member_tags_free(t); + u_free(arg); +} + +int utun_bridge_is_channel_owner(const char* channel_id) { + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return 0; + uint8_t priv[32]; + return topo_node_sqlite_channel_get_priv(db, channel_id, priv) == 0 ? 1 : 0; +} - int nt = supernode ? 4 : 1; - snprintf(sql, sizeof(sql), - "UPDATE \"%s\" SET adm_tags=?, adm_tags_sig=?, node_type=?, storage=? WHERE node_id=?", - peers_tbl); - sqlite3_stmt* st = NULL; - if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { - sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); - if (have_key) sqlite3_bind_blob(st, 2, sig, 64, SQLITE_STATIC); - else sqlite3_bind_null(st, 2); - sqlite3_bind_int(st, 3, nt); - sqlite3_bind_int(st, 4, storage_flag ? 1 : 0); - sqlite3_bind_int64(st, 5, (sqlite3_int64)node_id); - sqlite3_step(st); - sqlite3_finalize(st); - } - bridge_log(BLEV_INFO, "set_member_flags ch=%s nid=%llu admin=%d super=%d moder=%d storage=%d tags=%s have_key=%d", - channel_id, (unsigned long long)node_id, admin, supernode, moder, storage_flag, json, have_key); +void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, + int admin, int supernode, int moder, int storage_flag, + int verified, int deleted) { + if (!channel_id || node_id == 0) { bridge_log(BLEV_ERROR, "set_member_flags: invalid args"); return; } + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "set_member_flags: uasync not running"); return; } + struct member_tags_edit_req* req = u_calloc(1, sizeof(*req)); + if (!req) { bridge_log(BLEV_ERROR, "set_member_flags: OOM"); return; } + strncpy(req->ch_id, channel_id, sizeof(req->ch_id) - 1); + req->node_id = node_id; + req->admin = admin; req->supernode = supernode; req->moder = moder; + req->storage = storage_flag; req->verified = verified; req->deleted = deleted; + uasync_post(ua, member_tags_edit_trampoline, req); + bridge_log(BLEV_DEBUG, "set_member_flags queued ch=%s nid=0x%016llx admin=%d super=%d moder=%d storage=%d verified=%d deleted=%d", + channel_id, (unsigned long long)node_id, admin, supernode, moder, storage_flag, verified, deleted); } char* utun_bridge_get_member_links_json(uint64_t node_id) { @@ -1062,6 +997,22 @@ char* utun_bridge_get_member_links_json(uint64_t node_id) { return u_strdup(buf); } +/* ────────────────────────────────────────────────────────────────── + * Передача прав админа (канальный приватный ключ) другому узлу + * ────────────────────────────────────────────────────────────────── */ + +void utun_bridge_transfer_admin(const char* channel_id, uint64_t node_id) { + if (!channel_id || node_id == 0) { bridge_log(BLEV_ERROR, "transfer_admin: invalid args"); return; } + struct UASYNC* ua = instance_lite_get_uasync(); + if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "transfer_admin: uasync not running"); return; } + struct chat_transfer_admin_req* req = u_calloc(1, sizeof(*req)); + if (!req) { bridge_log(BLEV_ERROR, "transfer_admin: OOM"); return; } + strncpy(req->ch_id, channel_id, sizeof(req->ch_id) - 1); + req->target_node_id = node_id; + uasync_post(ua, chat_core_transfer_admin_trampoline, req); + bridge_log(BLEV_INFO, "transfer_admin queued ch=%s → 0x%016llx", channel_id, (unsigned long long)node_id); +} + /* ────────────────────────────────────────────────────────────────── * Local sockets state (for auto-mode UI display) * ────────────────────────────────────────────────────────────────── */ @@ -1982,12 +1933,30 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick( (*env)->ReleaseStringUTFChars(env, channelId, ch); } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeIsChannelOwner( + JNIEnv* env, jobject thiz, jstring channelId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + int owner = utun_bridge_is_channel_owner(ch); + (*env)->ReleaseStringUTFChars(env, channelId, ch); + return (jboolean)owner; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeTransferAdmin( + JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + utun_bridge_transfer_admin(ch, (uint64_t)nodeId); + (*env)->ReleaseStringUTFChars(env, channelId, ch); +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberFlags( JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, - jboolean admin, jboolean supernode, jboolean moder, jboolean storage) { + jboolean admin, jboolean supernode, jboolean moder, jboolean storage, + jboolean verified, jboolean deleted) { (void)thiz; const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); - utun_bridge_set_member_flags(ch, (uint64_t)nodeId, admin, supernode, moder, storage); + utun_bridge_set_member_flags(ch, (uint64_t)nodeId, admin, supernode, moder, storage, verified, deleted); (*env)->ReleaseStringUTFChars(env, channelId, ch); } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index a6b63f13..82527bcf 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -120,11 +120,16 @@ int utun_bridge_chat_setting_get_int(const char* name, int def); char* utun_bridge_get_channel_members_json(const char* channel_id); char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id); void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick); +int utun_bridge_is_channel_owner(const char* channel_id); void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id, - int admin, int supernode, int moder, int storage); + int admin, int supernode, int moder, int storage, + int verified, int deleted); char* utun_bridge_get_member_links_json(uint64_t node_id); +/* Передать канальный приватный ключ (права админа) другому узлу. */ +void utun_bridge_transfer_admin(const char* channel_id, uint64_t node_id); + /* ── Local sockets state (for auto-mode display) ── */ char* utun_bridge_get_local_sockets_json(void); diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 8d1b5981..35a42e86 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -334,6 +334,20 @@ void MainWindow::setupBridgeCallbacks() { QString chId = QString::fromUtf8(ch_id, ch_id_len); s_mainWindow->m_messageList->updateMessageProgress(chId, msg_id, blocks_done, num_blocks); }); + gui_bridge_set_admin_key_received_cb([](const uint8_t* data, int len) { + if (!s_mainWindow || len < 2) return; + uint8_t chLen = data[0]; + if (len < 1 + chLen + 8) return; + QString chId = QString::fromUtf8((const char*)data + 1, chLen); + uint64_t from; memcpy(&from, data + 1 + chLen, 8); + s_mainWindow->m_trayIcon->showMessage( + "vibechat", + QStringLiteral("You are now a channel co-owner.\nReceived admin key for channel %1 from 0x%2") + .arg(chId).arg(QString::number(from, 16)), + QSystemTrayIcon::Information, 5000); + if (s_mainWindow->m_currentChannelId == chId) + s_mainWindow->m_accountList->setChannel(chId); + }); } void MainWindow::setupConnects() { diff --git a/tools/chatgui/src/memberpropsdialog.cpp b/tools/chatgui/src/memberpropsdialog.cpp index dd02bc73..474b0afe 100644 --- a/tools/chatgui/src/memberpropsdialog.cpp +++ b/tools/chatgui/src/memberpropsdialog.cpp @@ -21,7 +21,9 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, , m_db(db) , m_channelId(channelId) , m_nodeId(nodeId) + , m_memberName(memberName) , m_flags(flags) + , m_transferBtn(nullptr) { setWindowTitle("Member Properties"); setMinimumWidth(340); @@ -117,6 +119,17 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, layout->addWidget(hint); } + m_transferBtn = new QPushButton("Transfer admin rights", this); + m_transferBtn->setEnabled(haveKey && !isOwn); + m_transferBtn->setToolTip(QStringLiteral("Send the channel private key to this member (encrypted + signed)")); + m_transferBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 6px 16px; font-size: 13px; background: palette(button); }" + "QPushButton:hover { background: palette(light); }" + "QPushButton:disabled { color: palette(midlight); }"); + connect(m_transferBtn, &QPushButton::clicked, this, &MemberPropsDialog::onTransferAdmin); + layout->addWidget(m_transferBtn); + layout->addStretch(); auto* btnLayout = new QHBoxLayout(); @@ -189,3 +202,29 @@ void MemberPropsDialog::onSave() { gui_bridge_post_uasync_fn(chat_member_tags_commit_trampoline, req); accept(); } + +void MemberPropsDialog::onTransferAdmin() { + if (m_edPrivkey.size() != 32 || m_nodeId == (uint64_t)m_db->myNodeId()) return; + + QString name = m_memberName.isEmpty() ? QString("0x%1").arg(m_nodeId, 16, 16, QChar('0')) : m_memberName; + QMessageBox::StandardButton answer = QMessageBox::question( + this, + QStringLiteral("Transfer admin rights"), + QStringLiteral("Send the channel private key to \"%1\"?\n\n" + "The key is transferred encrypted and signed. The recipient will become a co-owner " + "and will be able to manage members.").arg(name), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + + if (answer != QMessageBox::Yes) return; + + struct chat_transfer_admin_req* req = (struct chat_transfer_admin_req*) + u_calloc(1, sizeof(struct chat_transfer_admin_req)); + if (!req) return; + + QByteArray ch_id_utf8 = m_channelId.toUtf8(); + strncpy(req->ch_id, ch_id_utf8.constData(), sizeof(req->ch_id) - 1); + req->target_node_id = m_nodeId; + + gui_bridge_post_uasync_fn(chat_core_transfer_admin_trampoline, req); +} diff --git a/tools/chatgui/src/memberpropsdialog.h b/tools/chatgui/src/memberpropsdialog.h index f612e17d..244261fd 100644 --- a/tools/chatgui/src/memberpropsdialog.h +++ b/tools/chatgui/src/memberpropsdialog.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ public: private slots: void onSave(); + void onTransferAdmin(); private: DbManager* m_db; @@ -25,6 +27,7 @@ private: QByteArray m_edPrivkey; uint8_t m_flags; int m_currentVer; + QString m_memberName; QLineEdit* m_nickEdit; QCheckBox* m_adminCb; @@ -32,4 +35,5 @@ private: QCheckBox* m_storageCb; QCheckBox* m_verifiedCb; QCheckBox* m_deletedCb; + QPushButton* m_transferBtn; }; diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 53e239d2..5d224b1b 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -35,6 +35,7 @@ struct TOPO_GROUP_NODE; #define GUI_EVT_MEMBER_UPDATED 24 /* data: [ch_id_len:1][ch_id:var][count:1][member:79B] — обновление одного мембера */ #define GUI_EVT_MEMBER_REMOVED 25 /* data: [ch_id_len:1][ch_id:var][node_id:8] — мембер удалён из канала */ #define GUI_EVT_BGP_INFO 26 /* data: text — BGP hop summary for member detail */ +#define GUI_EVT_ADMIN_KEY_RECEIVED 28 /* data: [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ */ /* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */ #define MEMBER_DETAIL_LINK_SIZE 63 @@ -142,6 +143,10 @@ void gui_bridge_set_member_removed_cb(gui_member_removed_fn cb); typedef void (*gui_bgp_info_fn)(const uint8_t* data, int len); void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb); +/* Callback: получен канальный приватный ключ (стал совладельцем) — data: [ch_id_len:1][ch_id][from_node_id:8] */ +typedef void (*gui_admin_key_received_fn)(const uint8_t* data, int len); +void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb); + /* uTun nodeinfo callback — registered via utun_add_nodeinfo_cbk, called from uasync thread */ void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 626427b9..37e68892 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -45,6 +45,7 @@ static gui_member_list_fn g_member_list_cb = nullptr; static gui_member_updated_fn g_member_updated_cb = nullptr; static gui_member_removed_fn g_member_removed_cb = nullptr; static gui_bgp_info_fn g_bgp_info_cb = nullptr; +static gui_admin_key_received_fn g_admin_key_received_cb = nullptr; static struct UASYNC* g_ua = nullptr; /* ── GuiBridgeReceiver implementation ── */ @@ -198,6 +199,17 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { case GUI_EVT_BGP_INFO: if (g_bgp_info_cb) g_bgp_info_cb(d, dlen); break; + case GUI_EVT_ADMIN_KEY_RECEIVED: + if (dlen >= 2) { + uint8_t chLen = d[0]; + if (dlen >= 1 + chLen + 8) { + uint64_t from; memcpy(&from, d + 1 + chLen, 8); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: ADMIN_KEY_RECEIVED ch=%.*s from=0x%016llx", + chLen, (const char*)d + 1, (unsigned long long)from); + if (g_admin_key_received_cb) g_admin_key_received_cb(d, dlen); + } + } + break; default: DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType); break; @@ -319,6 +331,10 @@ void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb) { g_bgp_info_cb = cb; } +void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb) { + g_admin_key_received_cb = cb; +} + } /* extern "C" */ /* ── uTun nodeinfo callback (called from uasync thread) ── */