Browse Source

chat: права админа + member tags (verified/deleted) + ncd revive

- 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
v2
evgeny 4 weeks ago
parent
commit
631a176944
  1. 2
      src/Makefile.am
  2. 174
      src/chat/chat_admin.c
  3. 28
      src/chat/chat_admin.h
  4. 11
      src/chat/chat_core.h
  5. 1
      src/chat/chat_event.h
  6. 3
      src/chat/chat_sync.c
  7. 33
      src/routing_layer/topo_node_sqlite.c
  8. 8
      src/routing_layer/topo_node_sqlite.h
  9. 1
      src/transport_layer/etcp_api.h
  10. 69
      src/transport_layer/node_conn_direct.c
  11. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  12. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  13. 88
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  14. 27
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  15. 221
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  16. 7
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  17. 14
      tools/chatgui/src/mainwindow.cpp
  18. 39
      tools/chatgui/src/memberpropsdialog.cpp
  19. 4
      tools/chatgui/src/memberpropsdialog.h
  20. 5
      tools/chatgui/transport/gui_bridge.h
  21. 16
      tools/chatgui/transport/gui_bridge_impl.cpp

2
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 \

174
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 <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <openssl/evp.h>
#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);
}

28
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 <stdint.h>
#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 */

11
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 */

1
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);

3
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);

33
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;

8
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);

1
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 // соединение создано

69
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);
}
}

12
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 {

12
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 ── */

88
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<String>()
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") }
}
)
}
}

27
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -53,6 +53,9 @@ class ChatViewModel : ViewModel() {
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
val members: StateFlow<List<ChatMember>> = _members
private val _channelOwner = MutableStateFlow(false)
val channelOwner: StateFlow<Boolean> = _channelOwner
private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
val selectedMember: StateFlow<MemberDetail?> = _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 {

221
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);
}

7
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);

14
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() {

39
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);
}

4
tools/chatgui/src/memberpropsdialog.h

@ -3,6 +3,7 @@
#include <QDialog>
#include <QCheckBox>
#include <QLineEdit>
#include <QPushButton>
#include <QString>
#include <stdint.h>
@ -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;
};

5
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);

16
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) ── */

Loading…
Cancel
Save