Browse Source

chat: invite-ссылки с выбором достижимого узла + tcp_io фикс half-close FIN

- Общий модуль invite_build: сборка utun://-ссылок, авто-выбор лучшего
  достижимого узла, список кандидатов (вынесено из android jni_bridge)
- chatgui: диалог invite с выбором узла (комбобокс), события INVITE_CANDIDATES /
  INVITE_LINK_READY через gui_bridge
- headless/chatcli: invite <ch_id> [node_id], команда invite_nodes
- tcp_io: error_cb отличает грациозный EPOLLHUP (SO_ERROR==0) от реальной ошибки —
  при приостановленном чтении FIN не роняет недослейные данные (фикс flaky half-close)
- run_test.sh: half-close echo на выделенном порту 19190 + отдельный DNAT (был недостижим)
v2
evgeny 4 weeks ago
parent
commit
1345f44e10
  1. 20
      lib/tcp_io.c
  2. 2
      src/Makefile.am
  3. 2
      src/chat/chat_event.h
  4. 114
      src/chat/chat_headless_control.c
  5. 4
      src/chat/chat_headless_control.h
  6. 82
      src/chat/chat_msg.c
  7. 410
      src/chat/invite_build.c
  8. 49
      src/chat/invite_build.h
  9. 9
      tests/tcp_proxy_full/run_test.sh
  10. 16
      tools/chatcli
  11. 13
      tools/chatcli_commands.txt
  12. 200
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  13. 167
      tools/chatgui/src/invitedialog.cpp
  14. 23
      tools/chatgui/src/invitedialog.h
  15. 19
      tools/chatgui/src/mainwindow.cpp
  16. 90
      tools/chatgui/src/messagelist.cpp
  17. 2
      tools/chatgui/src/messagelist.h
  18. 21
      tools/chatgui/src/sound_manager.cpp
  19. 2
      tools/chatgui/src/sound_manager.h
  20. 10
      tools/chatgui/transport/gui_bridge.h
  21. 33
      tools/chatgui/transport/gui_bridge_impl.cpp
  22. 115
      tools/chatgui/transport/utun_node.cpp
  23. 4
      tools/chatgui/transport/utun_node.h

20
lib/tcp_io.c

@ -414,9 +414,23 @@ static void error_cb(socket_t sock, void* arg) {
(void)sock;
struct tcp_conn* tc = (struct tcp_conn*)arg;
if (!tc || tc->sock == SOCKET_INVALID) return;
DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: async error fd=%d connected=%d err=%d fin_r=%d fin_l=%d closed=%d wmon=%d",
(int)tc->sock, tc->connected, tc->error, tc->fin_remote, tc->fin_local, tc->closed, tc->write_monitor);
tcp_conn_handle_error(tc, -1);
int so_err = 0; socklen_t so_len = sizeof(so_err);
if (getsockopt(tc->sock, SOL_SOCKET, SO_ERROR, &so_err, &so_len) == 0 && so_err == 0) {
// Грациозное закрытие peer (FIN, EPOLLHUP) при приостановленном чтении: FIN пришёл не как
// EPOLLIN (чтение отключено backpressure-ом), а как HUP. Возобновляем чтение, чтобы
// read_cb дослил остаток и прочитал FIN (recv()==0 → fin_remote → отложенный on_fin).
DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: async HUP (graceful) fd=%d fin_r=%d fin_l=%d read_paused=%d rq=%d — resume read",
(int)tc->sock, tc->fin_remote, tc->fin_local, tc->read_paused,
tc->read_queue ? tc->read_queue->count : 0);
queue_waiter_cancel(tc->read_queue, &tc->read_waiter);
uasync_set_socket_read(tc->ua, tc->socket_id, 1);
tc->read_paused = 0;
read_cb(tc->sock, tc);
return;
}
DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: async error fd=%d connected=%d err=%d so_err=%d fin_r=%d fin_l=%d closed=%d wmon=%d",
(int)tc->sock, tc->connected, tc->error, so_err, tc->fin_remote, tc->fin_local, tc->closed, tc->write_monitor);
tcp_conn_handle_error(tc, so_err ? so_err : -1);
}
// ====================================================================

2
src/Makefile.am

@ -83,6 +83,7 @@ utun_CORE_SOURCES = \
chat/member_sync.c \
chat/merkle_sync.c \
chat/invite_link.c \
chat/invite_build.c \
chat/chat_headless_control.c \
broadcast.c
@ -166,6 +167,7 @@ libutun_a_SOURCES = \
chat/member_sync.c \
chat/merkle_sync.c \
chat/invite_link.c \
chat/invite_build.c \
chat/chat_headless_control.c \
broadcast.c
libutun_a_CFLAGS = $(utun_CFLAGS)

2
src/chat/chat_event.h

@ -47,6 +47,8 @@ extern "C" {
#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] — получен канальный ключ (стал совладельцем) */
#define CHAT_EVT_INVITE_LINK_READY 29 /* [ch_id_len:1][ch_id:var][link_len:2][link:var] — пустой link = ошибка */
#define CHAT_EVT_INVITE_CANDIDATES 30 /* [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */
typedef void (*chat_event_handler_fn)(int type, const uint8_t* data, int len);

114
src/chat/chat_headless_control.c

@ -6,6 +6,7 @@
*/
#include "chat_headless_control.h"
#include "invite_link.h"
#include "invite_build.h"
#include "chat_core.h"
#include "chat_sync.h"
#include "chat_event.h"
@ -301,76 +302,63 @@ static void hc_handle_send(struct headless_client* cli, int id, const char* json
}
static void hc_handle_invite(struct headless_client* cli, int id, const char* json) {
char ch[64];
char ch[64], node_id_str[32];
if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; }
if (!g_hc.inst) { send_response(cli, id, NULL, "no instance"); return; }
uint64_t my_node_id = g_hc.inst->node_id;
uint8_t my_pubkey[32]; memcpy(my_pubkey, g_hc.inst->my_keys.public_key, 32);
sqlite3* db = chat_core_get_db();
if (!db) { send_response(cli, id, NULL, "no database"); return; }
struct InviteData inv; memset(&inv, 0, sizeof(inv));
inv.channelId = strtoull(ch, NULL, 10);
if (inv.channelId == 0) { send_response(cli, id, NULL, "invalid channel_id"); return; }
memcpy(inv.pubkey, my_pubkey, 32);
inv.nodeId = my_node_id;
sqlite3_stmt* as = NULL;
sqlite3_prepare_v2(db, "SELECT family, protocol, address, port, rtt, socket_id FROM node_addresses WHERE node_id=? AND protocol IN (1,2) ORDER BY family, protocol",
-1, &as, NULL);
if (as) {
sqlite3_bind_int64(as, 1, (sqlite3_int64)my_node_id);
while (sqlite3_step(as) == SQLITE_ROW && inv.addrCount < INVITE_ADDR_MAX) {
struct InviteAddr* a = &inv.addrs[inv.addrCount++];
int fam = sqlite3_column_int(as, 0);
a->proto = (uint8_t)sqlite3_column_int(as, 1);
const uint8_t* addr = (const uint8_t*)sqlite3_column_blob(as, 2);
int addr_len = sqlite3_column_bytes(as, 2);
a->port = (uint16_t)sqlite3_column_int(as, 3);
a->socketId = (uint8_t)sqlite3_column_int(as, 5);
a->family = fam;
memset(a->address, 0, sizeof(a->address));
if (addr && addr_len > 0) memcpy(a->address, addr, addr_len < 16 ? (size_t)addr_len : 16);
}
sqlite3_finalize(as);
uint64_t channel_id = strtoull(ch, NULL, 10);
if (channel_id == 0) { send_response(cli, id, NULL, "invalid channel_id"); return; }
/* опциональный выбор узла: "0" или отсутствует → auto (лучший узел) */
uint64_t target_node_id = 0;
if (json_get_str(json, "node_id", node_id_str, sizeof(node_id_str)) == 0)
target_node_id = strtoull(node_id_str, NULL, 16);
char link[1024];
if (chat_invite_build_link(channel_id, target_node_id, NULL, link, sizeof(link)) < 0) {
send_response(cli, id, NULL, "failed to build invite link (see log)");
return;
}
if (inv.addrCount == 0) {
/* fallback: collect from sockets directly */
struct ETCP_SOCKET* s = g_hc.inst->etcp_sockets;
while (s && inv.addrCount < INVITE_ADDR_MAX) {
if (s->is_tcp) { s = s->next; continue; }
if (s->local_addr.ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)&s->local_addr;
struct InviteAddr* a = &inv.addrs[inv.addrCount++];
a->family = 4; a->proto = 1; a->socketId = s->sock_id;
a->port = ntohs(sin->sin_port); memcpy(a->address, &sin->sin_addr, 4);
}
s = s->next;
}
struct ETCP_SOCKET* ts = g_hc.inst->etcp_sockets;
while (ts && inv.addrCount < INVITE_ADDR_MAX) {
if (!ts->is_tcp) { ts = ts->next; continue; }
if (ts->interface_addr.ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)&ts->interface_addr;
struct InviteAddr* a = &inv.addrs[inv.addrCount++];
a->family = 4; a->proto = 2; a->socketId = ts->sock_id;
a->port = ntohs(sin->sin_port); memcpy(a->address, &sin->sin_addr, 4);
}
ts = ts->next;
}
struct InviteData d; char err[128];
if (invite_link_decode(link, strlen(link), &d, err, sizeof(err)) < 0) {
send_response(cli, id, NULL, "failed to decode built link");
return;
}
if (inv.addrCount == 0) { send_response(cli, id, NULL, "no addresses available to build invite link"); return; }
DEBUG_INFO((int)DEBUG_CATEGORY_HEADLESS, "headless: invite ch=%s target=0x%016llx node=0x%016llx addrs=%d",
ch, (unsigned long long)target_node_id, (unsigned long long)d.nodeId, (int)d.addrCount);
char link[1024];
int r = invite_link_encode(&inv, NULL, link, sizeof(link));
if (r < 0) { send_response(cli, id, NULL, "failed to encode invite link"); return; }
char resp[1280];
snprintf(resp, sizeof(resp),
"{\"link\":\"%s\",\"channel_id\":%llu,\"node_id\":\"0x%016llx\",\"addrs\":%d}",
link, (unsigned long long)d.channelId, (unsigned long long)d.nodeId, (int)d.addrCount);
send_response(cli, id, resp, NULL);
}
char resp[1280]; snprintf(resp, sizeof(resp), "{\"link\":\"%s\",\"channel_id\":%llu,\"addrs\":%d}", link,
(unsigned long long)inv.channelId, (int)inv.addrCount);
static void hc_handle_invite_nodes(struct headless_client* cli, int id, const char* json) {
char ch[64];
if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; }
uint64_t channel_id = strtoull(ch, NULL, 10);
if (channel_id == 0) { send_response(cli, id, NULL, "invalid channel_id"); return; }
uint64_t ids[INVITE_ADDR_MAX];
int cnt = chat_invite_candidate_nodes(channel_id, ids, INVITE_ADDR_MAX);
if (cnt < 0) { send_response(cli, id, NULL, "no candidates (see log)"); return; }
uint64_t auto_id = 0;
chat_invite_best_node(channel_id, &auto_id);
char resp[4096]; int off = 0;
off += snprintf(resp + off, sizeof(resp) - off, "{\"ch\":\"%s\",\"auto_node_id\":\"0x%016llx\",\"nodes\":[",
ch, (unsigned long long)auto_id);
for (int i = 0; i < cnt && off < (int)sizeof(resp) - 256; i++) {
char name[64] = "";
chat_core_get_node_name(ids[i], name, sizeof(name));
off += snprintf(resp + off, sizeof(resp) - off, "%s{\"node_id\":\"0x%016llx\",\"name\":\"%s\"}",
i ? "," : "", (unsigned long long)ids[i], name);
}
off += snprintf(resp + off, sizeof(resp) - off, "]}");
send_response(cli, id, resp, NULL);
}
@ -476,8 +464,8 @@ static void hc_handle_quit(struct headless_client* cli, int id, const char* json
typedef void (*hc_cmd_fn)(struct headless_client* cli, int id, const char* json);
static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "invite", "connect", "create_channel", "invite_to", "subscribe", "quit", NULL };
static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_invite, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_quit };
static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "quit", NULL };
static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_quit };
void hc_handle_command(struct headless_client* cli, const char* json) {
if (!cli || !json) return;

4
src/chat/chat_headless_control.h

@ -6,8 +6,8 @@
* Response: {"id":N,"ok":true,"data":{...}} | {"id":N,"ok":false,"error":"..."}
* Event: {"event":"<type>",...}
*
* Commands: ping, status, channels, members, messages, send, invite, connect,
* create_channel, subscribe, quit
* Commands: ping, status, channels, members, messages, send, invite,
* invite_nodes, connect, create_channel, subscribe, quit
*/
#ifndef CHAT_HEADLESS_CONTROL_H
#define CHAT_HEADLESS_CONTROL_H

82
src/chat/chat_msg.c

@ -94,8 +94,7 @@ void chat_core_submit_trampoline(void* arg) {
struct media_submit_ctx {
char channel_id[64];
char content_type[32];
char media_dest[512];
char media_base[512];
char media_dest[1024];
uint8_t msg_data[4096];
uint32_t msg_data_len;
uint64_t timestamp;
@ -177,7 +176,7 @@ static void on_media_registered(void* arg, int err, const struct media_index_res
CC_ID, mctx->channel_id, (unsigned long long)ts);
const char* base = strrchr(mctx->media_dest, '/');
const char* filename = base ? base + 1 : mctx->media_dest;
char attrs[320]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", filename);
char attrs[1100]; snprintf(attrs, sizeof(attrs), "{\"st\":\"fl\",\"fp\":\"%s\"}", filename);
chat_core_update_local_attrs(mctx->channel_id, ts, json_sig, attrs);
/* whisper транскрипция для своих голосовых сообщений */
@ -222,7 +221,6 @@ static void chat_core_submit_media_message(struct chat_msg_submit* req) {
snprintf(mctx->channel_id, sizeof(mctx->channel_id), "%s", req->channel_id);
snprintf(mctx->content_type, sizeof(mctx->content_type), "%s", req->content_type);
snprintf(mctx->media_dest, sizeof(mctx->media_dest), "%s", req->media_dest);
snprintf(mctx->media_base, sizeof(mctx->media_base), "%s", media_base);
mctx->timestamp = req->timestamp;
if (req->data && req->data_len > 0) {
mctx->msg_data_len = req->data_len < sizeof(mctx->msg_data) ? req->data_len : sizeof(mctx->msg_data) - 1;
@ -557,6 +555,22 @@ static void sanitize_filename(char* buf, size_t size) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "%s: filename sanitized [%s] -> [%s]", CC_ID, orig, buf);
}
/* Извлекает имя файла из тела "d"-поля медиасообщения: берёт сегмент до '|',
декодирует base64url-префикс '*', если он есть, и санитизирует. */
static void extract_base_filename(const char* body, char* out, size_t out_sz) {
size_t bfi = 0;
while (body[bfi] && body[bfi] != '|' && bfi < out_sz - 1)
out[bfi++] = body[bfi];
out[bfi] = '\0';
if (out[0] == '*' && bfi > 1) {
uint8_t dec[256];
int dlen = (int)b64_decode(out + 1, bfi - 1, dec, sizeof(dec));
if (dlen > 0 && dlen < (int)out_sz) { memcpy(out, dec, (size_t)dlen); out[dlen] = '\0'; }
}
if (bfi == 0) snprintf(out, out_sz, "file");
sanitize_filename(out, out_sz);
}
static int md_start_download(struct UTUN_INSTANCE* inst,
const char* data_str, size_t data_len,
const char* ch_id, const char* base_filename,
@ -607,6 +621,16 @@ static int md_start_download(struct UTUN_INSTANCE* inst,
p += 128; if (n < nb - 1 && *p == ',') p++;
}
/* для голосовых имя генерируем из media_id: msg_data — это wf-строка, а не имя файла */
char final_name[256];
if (content_type && strcmp(content_type, "audio/opus") == 0) {
char hex[33];
for (int i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", result.media_id[i]);
snprintf(final_name, sizeof(final_name), "voice_%s.opus", hex);
} else {
snprintf(final_name, sizeof(final_name), "%s", base_filename);
}
char dest[1024], media_base[512];
{
const char* last_slash = strrchr(g_cc.db_path, '/');
@ -616,23 +640,21 @@ static int md_start_download(struct UTUN_INSTANCE* inst,
snprintf(media_base, sizeof(media_base), "%s", g_cc.db_path);
}
}
snprintf(dest, sizeof(dest), "%s/media/%s/%s", media_base, ch_id, base_filename);
const char* fp_name = strrchr(dest, '/'); fp_name = fp_name ? fp_name + 1 : dest;
snprintf(dest, sizeof(dest), "%s/media/%s/%s", media_base, ch_id, final_name);
struct md_done_ctx* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) { media_index_result_free(&result); return -1; }
snprintf(ctx->channel_id, sizeof(ctx->channel_id), "%s", ch_id);
ctx->ts = ts; ctx->msg_id = msg_id;
snprintf(ctx->dest_relpath, sizeof(ctx->dest_relpath), "%s", fp_name);
snprintf(ctx->dest_relpath, sizeof(ctx->dest_relpath), "%s", final_name);
snprintf(ctx->dest_abs_path, sizeof(ctx->dest_abs_path), "%s", dest);
ctx->author_node_id = author_node_id;
if (content_type) snprintf(ctx->content_type, sizeof(ctx->content_type), "%s", content_type);
else ctx->content_type[0] = '\0';
if (author_sig) memcpy(ctx->author_sig, author_sig, 64);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: download start ch=%s file=%s blocks=%d size=%lld dest=%s",
CC_ID, ch_id, base_filename, nb, (long long)fsize, dest);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: download start ch=%s ct=%s file=%s blocks=%d size=%lld dest=%s",
CC_ID, ch_id, content_type ? content_type : "?", final_name, nb, (long long)fsize, dest);
uint64_t gid = strtoull(ch_id, NULL, 10);
media_download_start(inst, gid, &result, dest, media_base, author_node_id, md_download_done_cb, ctx, md_download_progress_cb, ctx);
media_index_result_free(&result);
@ -690,17 +712,8 @@ void on_msg_inserted(struct DB_SYNC_INSTANCE* si, uint64_t record_ts, const char
while (*d_start && *d_start != '\"' && bi < sizeof(body) - 1) body[bi++] = *d_start++;
body[bi] = '\0';
char base_filename[256]; size_t bfi = 0;
while (body[bfi] && body[bfi] != '|' && bfi < sizeof(base_filename) - 1)
base_filename[bfi++] = body[bfi];
base_filename[bfi] = '\0';
/* base64-decode display prefix if marked with '*' (new format) */
if (base_filename[0] == '*' && bfi > 1) {
uint8_t dec[256]; int dlen = (int)b64_decode(base_filename + 1, bfi - 1, dec, sizeof(dec));
if (dlen > 0 && dlen < (int)sizeof(base_filename)) { memcpy(base_filename, dec, (size_t)dlen); base_filename[dlen] = '\0'; }
}
if (bfi == 0) snprintf(base_filename, sizeof(base_filename), "file");
sanitize_filename(base_filename, sizeof(base_filename));
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
const uint8_t* sig_field = (const uint8_t*)strstr(buf, "\"sig\":\"");
uint8_t author_sig[64] = {0};
@ -777,23 +790,26 @@ void chat_core_attachment_download(const char* channel_id, int64_t msg_id) {
while (*d_start && *d_start != '\"' && bi < sizeof(body) - 1) body[bi++] = *d_start++;
body[bi] = '\0';
char base_filename[256]; size_t bfi = 0;
while (body[bfi] && body[bfi] != '|' && bfi < sizeof(base_filename) - 1)
base_filename[bfi++] = body[bfi];
base_filename[bfi] = '\0';
if (base_filename[0] == '*' && bfi > 1) {
uint8_t dec[256]; int dlen = (int)b64_decode(base_filename + 1, bfi - 1, dec, sizeof(dec));
if (dlen > 0 && dlen < (int)sizeof(base_filename)) { memcpy(base_filename, dec, (size_t)dlen); base_filename[dlen] = '\0'; }
char content_type[32] = {0};
{
const char* ct = strstr((const char*)jdata, "\"ct\":\"");
if (ct) {
const char* cv = ct + 6;
int i = 0;
while (cv[i] && cv[i] != '\"' && i < (int)sizeof(content_type) - 1) content_type[i++] = cv[i];
content_type[i] = '\0';
}
}
if (bfi == 0) snprintf(base_filename, sizeof(base_filename), "file");
sanitize_filename(base_filename, sizeof(base_filename));
char base_filename[256];
extract_base_filename(body, base_filename, sizeof(base_filename));
uint8_t author_sig[64]; memcpy(author_sig, sig_blob, 64);
sqlite3_finalize(st);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: manual download ch=%s id=%lld file=%s",
CC_ID, channel_id, (long long)msg_id, base_filename);
md_start_download(g_cc.inst, body, bi, channel_id, base_filename, (uint64_t)db_ts, author_sig, (uint64_t)author_node_id, msg_id, NULL);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "%s: manual download ch=%s id=%lld ct=%s file=%s",
CC_ID, channel_id, (long long)msg_id, content_type, base_filename);
md_start_download(g_cc.inst, body, bi, channel_id, base_filename, (uint64_t)db_ts, author_sig, (uint64_t)author_node_id, msg_id, content_type);
}
static void chat_core_attachment_download_trampoline_impl(void* arg) {

410
src/chat/invite_build.c

@ -0,0 +1,410 @@
/*
* invite_build.c — сборка invite-ссылок utun:// (выбор узла + адреса)
*
* Общий модуль для всех GUI (desktop chatgui, Android, headless CLI).
* Логика выбора «лучшего узла» перенесена из tools/chatgui-android/jni_bridge.
* Работает в uasync-потоке, использует общий контекст g_cc.
*/
#include "invite_build.h"
#include "invite_link.h"
#include "chat_core_priv.h"
#include "chat_event.h"
#include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node.h"
#include "../utun_instance.h"
#include "../transport_layer/etcp_connections.h"
#include "../../lib/mem.h"
#include "../../lib/platform_compat.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* ── живость узла в канале (BGP/прямое/через посредника) ── */
static struct TOPO_GROUP* resolve_chat_group(uint64_t channel_id) {
if (!g_cc.inst || !g_cc.inst->topo_groups) return NULL;
struct TOPO_GROUP* grp = topo_groups_find(g_cc.inst->topo_groups, channel_id);
if (!grp || grp->group_type != TOPO_GROUP_TYPE_CHAT) return NULL;
return grp;
}
static int node_is_alive(struct TOPO_GROUP* grp, uint64_t node_id) {
if (node_id == g_cc.my_node_id) return 1; /* self всегда доступен */
if (!grp) return 0;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(grp, node_id);
if (!nq) return 0;
return (nq->conn_presence & (NCONN_DIRECT | NCONN_INDIRECT | NCONN_BGP)) != 0;
}
/* есть ли у узла публичные адреса v4 и v6 (addr_type=DIRECT) */
static int node_has_both_public(uint64_t node_id) {
int has_v4 = 0, has_v6 = 0;
sqlite3_stmt* st = NULL;
char sql[256];
snprintf(sql, sizeof(sql),
"SELECT family FROM node_addresses WHERE node_id=? AND addr_type=%d", ADDR_TYPE_DIRECT);
if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) return 0;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
while (sqlite3_step(st) == SQLITE_ROW) {
int fam = sqlite3_column_int(st, 0);
if (fam == 4) has_v4 = 1;
else if (fam == 6) has_v6 = 1;
}
sqlite3_finalize(st);
return (has_v4 && has_v6) ? 1 : 0;
}
/* ── Фаза 1-2: лучший достижимый узел в канале ── */
static uint64_t pick_best_node(uint64_t channel_id, int* out_has_both_public, int* out_priority) {
uint64_t best_nid = 0;
int best_priority = 4; /* 1=regular, 2=self, 3=super, 4=invalid */
int best_has_both = 0;
struct TOPO_GROUP* grp = resolve_chat_group(channel_id);
if (!grp) {
*out_has_both_public = 0;
*out_priority = best_priority;
return 0;
}
char ch_str[32];
snprintf(ch_str, sizeof(ch_str), "%llu", (unsigned long long)channel_id);
char peers_tbl[80];
peers_table_name(ch_str, peers_tbl, sizeof(peers_tbl));
char sql[512];
snprintf(sql, sizeof(sql), "SELECT node_id, node_type FROM \"%s\" ORDER BY node_id", peers_tbl);
sqlite3_stmt* pst = NULL;
if (sqlite3_prepare_v2(g_cc.db, sql, -1, &pst, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: pick_best — query failed ch=%llu err=%s",
(unsigned long long)channel_id, sqlite3_errmsg(g_cc.db));
*out_has_both_public = 0;
*out_priority = best_priority;
return 0;
}
while (sqlite3_step(pst) == SQLITE_ROW) {
uint64_t nid = (uint64_t)sqlite3_column_int64(pst, 0);
int ntype = sqlite3_column_int(pst, 1);
if (!node_is_alive(grp, nid)) continue;
int has_both = node_has_both_public(nid);
int priority;
if (nid == g_cc.my_node_id) priority = 2; /* self */
else if (ntype == 4) priority = 3; /* supernode */
else priority = 1; /* regular */
if (priority <= best_priority) {
best_nid = nid;
best_priority = priority;
best_has_both = has_both;
}
}
sqlite3_finalize(pst);
*out_has_both_public = best_has_both;
*out_priority = best_priority;
return best_nid;
}
/* авто-узел: pick_best_node + фолбэк на self */
static uint64_t resolve_auto_node(uint64_t channel_id, int* out_has_both, int* out_priority) {
int has_both = 0, prio = 4;
uint64_t best = pick_best_node(channel_id, &has_both, &prio);
if (best == 0) best = g_cc.my_node_id;
if (out_has_both) *out_has_both = has_both;
if (out_priority) *out_priority = prio;
return best;
}
/* ── Фаза 3: pubkey узла ── */
static int get_node_pubkey(uint64_t node_id, int is_self, uint8_t pubkey_out[INVITE_PUBKEY_SIZE]) {
if (is_self) {
if (!g_cc.inst) return -1;
memcpy(pubkey_out, g_cc.inst->my_keys.public_key, INVITE_PUBKEY_SIZE);
return 0;
}
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(g_cc.db, "SELECT x25519_pubkey FROM nodes WHERE node_id=?",
-1, &st, NULL) != SQLITE_OK) return -1;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
int ok = 0;
if (sqlite3_step(st) == SQLITE_ROW) {
const void* pk = sqlite3_column_blob(st, 0);
int pklen = sqlite3_column_bytes(st, 0);
if (pk && pklen >= INVITE_PUBKEY_SIZE) { memcpy(pubkey_out, pk, INVITE_PUBKEY_SIZE); ok = 1; }
}
sqlite3_finalize(st);
return ok ? 0 : -1;
}
/* ── Фаза 4a: свои адреса из сокетов ── */
static int collect_self_addrs(struct InviteAddr* out, int max_cnt) {
int cnt = 0;
if (!g_cc.inst) return 0;
struct ETCP_SOCKET* s;
/* UDP */
for (s = g_cc.inst->etcp_sockets; s && cnt < max_cnt; s = s->next) {
if (s->is_tcp) continue;
struct sockaddr_storage* sa = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
}
/* TCP */
for (s = g_cc.inst->etcp_sockets; s && cnt < max_cnt; s = s->next) {
if (!s->is_tcp) continue;
struct sockaddr_storage* sa = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
}
return cnt;
}
/* ── Фаза 4b: адреса узла из БД ── */
static int collect_node_addrs_from_db(uint64_t node_id, struct InviteAddr* out, int max_cnt, int dir_only) {
char sql[256];
if (dir_only)
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses"
" WHERE node_id=? AND addr_type=%d", ADDR_TYPE_DIRECT);
else
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses WHERE node_id=?");
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) return 0;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
int cnt = 0;
while (sqlite3_step(st) == SQLITE_ROW && cnt < max_cnt) {
int family = sqlite3_column_int(st, 0);
out[cnt].family = family;
out[cnt].socketId = (uint8_t)sqlite3_column_int(st, 1);
out[cnt].proto = (uint8_t)sqlite3_column_int(st, 2);
const uint8_t* addr = sqlite3_column_blob(st, 3);
int addr_len = sqlite3_column_bytes(st, 3);
int ip_len = (family == 6) ? 16 : 4;
memset(out[cnt].address, 0, sizeof(out[cnt].address));
if (addr && addr_len >= ip_len) memcpy(out[cnt].address, addr, (size_t)ip_len);
out[cnt].port = (uint16_t)sqlite3_column_int(st, 4);
cnt++;
}
sqlite3_finalize(st);
return cnt;
}
/* ── публичный API ── */
int chat_invite_build_link(uint64_t channel_id, uint64_t target_node_id,
const char* password, char* out, size_t out_size) {
if (!out || out_size == 0) return -1;
out[0] = '\0';
if (!g_cc.initialized || !g_cc.inst || !g_cc.db) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: not initialized (inst=%p db=%p)",
(void*)g_cc.inst, (void*)g_cc.db);
return -1;
}
if (channel_id == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: invalid channel_id");
return -1;
}
/* ── выбор узла ── */
uint64_t target_nid = target_node_id;
int use_self = 0, has_both_public = 0;
if (target_nid == 0) {
int prio = 4;
target_nid = resolve_auto_node(channel_id, &has_both_public, &prio);
use_self = (target_nid == g_cc.my_node_id) ? 1 : 0;
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: auto ch=%llu best=0x%016llx prio=%d self=%d both=%d",
(unsigned long long)channel_id, (unsigned long long)target_nid,
prio, use_self, has_both_public);
} else {
if (target_nid == g_cc.my_node_id) {
use_self = 1;
} else {
struct TOPO_GROUP* grp = resolve_chat_group(channel_id);
if (!node_is_alive(grp, target_nid)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC,
"invite_build: node 0x%016llx not reachable in ch=%llu",
(unsigned long long)target_nid, (unsigned long long)channel_id);
return -1;
}
has_both_public = node_has_both_public(target_nid);
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: manual ch=%llu target=0x%016llx self=%d both=%d",
(unsigned long long)channel_id, (unsigned long long)target_nid, use_self, has_both_public);
}
/* ── pubkey ── */
struct InviteData data;
memset(&data, 0, sizeof(data));
data.channelId = channel_id;
data.nodeId = target_nid;
if (get_node_pubkey(target_nid, use_self, data.pubkey) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: no pubkey for node=0x%016llx",
(unsigned long long)target_nid);
return -1;
}
/* ── адреса ── */
if (use_self) {
data.addrCount = (uint8_t)collect_self_addrs(data.addrs, INVITE_ADDR_MAX);
} else {
int dir_only = has_both_public ? 1 : 0;
data.addrCount = (uint8_t)collect_node_addrs_from_db(target_nid, data.addrs, INVITE_ADDR_MAX, dir_only);
}
if (data.addrCount == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: no addrs for node=0x%016llx",
(unsigned long long)target_nid);
return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: node=0x%016llx addrs=%d dir_only=%d",
(unsigned long long)target_nid, data.addrCount, (!use_self && has_both_public) ? 1 : 0);
/* ── encode ── */
if (invite_link_encode(&data, password, out, out_size) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: encode failed ch=%llu",
(unsigned long long)channel_id);
return -1;
}
return 0;
}
int chat_invite_best_node(uint64_t channel_id, uint64_t* out_node_id) {
if (!out_node_id) return -1;
if (!g_cc.initialized || !g_cc.inst || !g_cc.db) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: best_node — not initialized");
return -1;
}
*out_node_id = resolve_auto_node(channel_id, NULL, NULL);
return 0;
}
int chat_invite_candidate_nodes(uint64_t channel_id, uint64_t* out_ids, int max_ids) {
if (!out_ids || max_ids <= 0) return -1;
if (!g_cc.initialized || !g_cc.inst || !g_cc.db) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: candidates — not initialized");
return -1;
}
struct TOPO_GROUP* grp = resolve_chat_group(channel_id);
if (!grp) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: candidates — group not found ch=%llu",
(unsigned long long)channel_id);
return -1;
}
char ch_str[32];
snprintf(ch_str, sizeof(ch_str), "%llu", (unsigned long long)channel_id);
char peers_tbl[80];
peers_table_name(ch_str, peers_tbl, sizeof(peers_tbl));
char sql[512];
snprintf(sql, sizeof(sql), "SELECT node_id FROM \"%s\" ORDER BY node_id", peers_tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(g_cc.db, sql, -1, &st, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: candidates — query failed ch=%llu err=%s",
(unsigned long long)channel_id, sqlite3_errmsg(g_cc.db));
return -1;
}
int cnt = 0;
while (sqlite3_step(st) == SQLITE_ROW && cnt < max_ids) {
uint64_t nid = (uint64_t)sqlite3_column_int64(st, 0);
if (node_is_alive(grp, nid)) out_ids[cnt++] = nid;
}
sqlite3_finalize(st);
return cnt;
}
/* ── трамплины для gui_bridge ── */
void chat_invite_candidates_trampoline(void* arg) {
if (!arg) return;
char ch_id[64];
strncpy(ch_id, (const char*)arg, sizeof(ch_id) - 1);
ch_id[sizeof(ch_id) - 1] = '\0';
u_free(arg);
if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "invite_build: candidates — not initialized"); return; }
uint64_t channel_id = strtoull(ch_id, NULL, 10);
uint64_t auto_id = 0;
chat_invite_best_node(channel_id, &auto_id);
uint64_t ids[INVITE_ADDR_MAX];
int cnt = chat_invite_candidate_nodes(channel_id, ids, INVITE_ADDR_MAX);
if (cnt < 0) cnt = 0;
size_t ch_len = strlen(ch_id);
size_t evt_sz = 1 + ch_len + 8 + 2 + (size_t)cnt * 8;
uint8_t* evt = u_malloc(evt_sz);
if (!evt) return;
evt[0] = (uint8_t)ch_len;
memcpy(evt + 1, ch_id, ch_len);
memcpy(evt + 1 + ch_len, &auto_id, 8);
uint16_t u16 = (uint16_t)cnt;
memcpy(evt + 1 + ch_len + 8, &u16, 2);
for (int i = 0; i < cnt; i++) memcpy(evt + 1 + ch_len + 8 + 2 + i * 8, &ids[i], 8);
chat_event_post(CHAT_EVT_INVITE_CANDIDATES, evt, (int)evt_sz);
u_free(evt);
}
void chat_invite_build_link_trampoline(void* arg) {
struct chat_invite_build_req* req = (struct chat_invite_build_req*)arg;
if (!req) return;
uint64_t channel_id = strtoull(req->ch_id, NULL, 10);
uint64_t target = req->target_node_id;
char password[INVITE_PASS_MAX];
password[0] = '\0';
const char* pass = NULL;
if (req->password[0]) {
strncpy(password, req->password, sizeof(password) - 1);
password[sizeof(password) - 1] = '\0';
pass = password;
}
char link[1024];
int ok = chat_invite_build_link(channel_id, target, pass, link, sizeof(link));
size_t ch_len = strlen(req->ch_id);
size_t link_len = (ok == 0) ? strlen(link) : 0;
size_t evt_sz = 1 + ch_len + 2 + link_len;
uint8_t* evt = u_malloc(evt_sz);
if (!evt) { u_free(req); return; }
evt[0] = (uint8_t)ch_len;
memcpy(evt + 1, req->ch_id, ch_len);
uint16_t u16 = (uint16_t)link_len;
memcpy(evt + 1 + ch_len, &u16, 2);
if (link_len) memcpy(evt + 1 + ch_len + 2, link, link_len);
chat_event_post(CHAT_EVT_INVITE_LINK_READY, evt, (int)evt_sz);
u_free(evt);
u_free(req);
}

49
src/chat/invite_build.h

@ -0,0 +1,49 @@
/*
* invite_build.h — сборка invite-ссылок utun:// (выбор узла + адреса)
*
* Общий модуль для всех GUI (desktop chatgui, Android, headless CLI).
* Логика выбора «лучшего узла» перенесена из tools/chatgui-android/jni_bridge.
* Работает в uasync-потоке, использует общий контекст g_cc.
*/
#ifndef INVITE_BUILD_H
#define INVITE_BUILD_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Собрать invite-ссылку для канала.
* target_node_id = 0 → auto (лучший достижимый мембер, иначе self).
* target_node_id != 0 → конкретный узел (должен быть достижимым мембером канала).
* password = NULL → без пароля.
* Возвращает 0 и заполняет out, либо -1 (детали в логе). */
int chat_invite_build_link(uint64_t channel_id, uint64_t target_node_id,
const char* password, char* out, size_t out_size);
/* Список достижимых узлов-кандидатов канала (включая self) — тот же фильтр, что в auto.
* Возвращает количество (0..max_ids), либо -1. */
int chat_invite_candidate_nodes(uint64_t channel_id, uint64_t* out_ids, int max_ids);
/* Авто-узел: лучший достижимый мембер, иначе self. Возвращает 0 и заполняет out_node_id, либо -1. */
int chat_invite_best_node(uint64_t channel_id, uint64_t* out_node_id);
/* Трамплин для gui_bridge (arg = u_malloc(struct chat_invite_build_req)).
* Результат: CHAT_EVT_INVITE_LINK_READY. */
struct chat_invite_build_req {
char ch_id[64];
uint64_t target_node_id; /* 0 = auto */
char password[128];
};
void chat_invite_build_link_trampoline(void* arg);
/* Трамплин (arg = u_strdup(ch_id)). Результат: CHAT_EVT_INVITE_CANDIDATES
* (формат: [ch_id_len:1][ch_id][auto_node_id:8][count:2][node_id:8]*). */
void chat_invite_candidates_trampoline(void* arg);
#ifdef __cplusplus
}
#endif
#endif /* INVITE_BUILD_H */

9
tests/tcp_proxy_full/run_test.sh

@ -18,7 +18,7 @@ ECHO_PY="$SCRIPT_DIR/echo_server.py"
STRESS_PY="$SCRIPT_DIR/stress_client.py"
ECHO_PORT=19090
HC_PORT=19091
HC_PORT=19190
REFUSED_PORT=19099
N_IP=20
@ -36,7 +36,11 @@ setup_net() {
gw=$(ip route show default | awk '/via/ {print $3; exit}')
[ -n "$gw" ] && ip route replace default via "$gw" table 100 2>/dev/null || true
# DNAT: unmarked packets to fake IPs → real localhost
# DNAT: unmarked packets to fake IPs → real localhost.
# half-close echo (dedicated port, вне stress-диапазона 19090-19109) — ДО общего правила,
# иначе порт перезапишется на ECHO_PORT.
iptables -t nat -C OUTPUT -d 10.200.100.1 -p tcp --dport "$HC_PORT" -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$HC_PORT" 2>/dev/null \
|| iptables -t nat -A OUTPUT -d 10.200.100.1 -p tcp --dport "$HC_PORT" -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$HC_PORT"
for i in $(seq 1 $N_IP); do
iptables -t nat -C OUTPUT -d "10.200.100.${i}" -p tcp -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$ECHO_PORT" 2>/dev/null \
|| iptables -t nat -A OUTPUT -d "10.200.100.${i}" -p tcp -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$ECHO_PORT"
@ -63,6 +67,7 @@ setup_tun_route() {
cleanup_net() {
echo "=== Cleaning up networking ==="
iptables -t nat -D OUTPUT -d 10.200.100.1 -p tcp --dport "$HC_PORT" -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$HC_PORT" 2>/dev/null || true
for i in $(seq 1 $N_IP); do
iptables -t nat -D OUTPUT -d "10.200.100.${i}" -p tcp -m mark ! --mark 1 -j DNAT --to-destination 127.0.0.1:"$ECHO_PORT" 2>/dev/null || true
done

16
tools/chatcli

@ -14,7 +14,8 @@
# members <ch_id> list members with full state
# messages <ch_id> [count] read last messages
# send <ch_id> <text> send text message
# invite <ch_id> create invite link
# invite <ch_id> [node_id] create invite link (auto best node by default)
# invite_nodes <ch_id> list reachable nodes for invite
# connect <utun://...> join channel via invite link
# create <name> create new channel
# invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]
@ -97,8 +98,14 @@ def cmd_messages(ch_id, count=None):
def cmd_send(ch_id, text):
_req("send", ch=ch_id, content_type="text", data=text)
def cmd_invite(ch_id):
_req("invite", ch=ch_id)
def cmd_invite(ch_id, node_id=None):
params = {"ch": ch_id}
if node_id:
params["node_id"] = node_id
_req("invite", **params)
def cmd_invite_nodes(ch_id):
_req("invite_nodes", ch=ch_id)
def cmd_connect(link):
_req("connect", link=link)
@ -166,7 +173,8 @@ def main():
elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members <ch_id>"),))
elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages <ch_id> [count]"),))
elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send <ch_id> <text>"),))
elif cmd == "invite": cmd_invite(*args[1:2] if len(args) > 1 else (_die("usage: invite <ch_id>"),))
elif cmd == "invite": cmd_invite(*args[1:3] if len(args) > 1 else (_die("usage: invite <ch_id> [node_id]"),))
elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes <ch_id>"),))
elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect <utun://...>"),))
elif cmd == "create": cmd_create(*args[1:2] if len(args) > 1 else (_die("usage: create <name>"),))
elif cmd == "invite_to": cmd_invite_to(*args[1:6] if len(args) > 4 else (_die("usage: invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]"),))

13
tools/chatcli_commands.txt

@ -49,10 +49,19 @@ send <ch_id> <text>
Отправить текстовое сообщение в канал.
Пример: chatcli send 12345 "hello world"
invite <ch_id>
invite <ch_id> [node_id]
Создать invite-ссылку для канала (формат utun://...).
Ссылка включает pubkey, адреса сервера и ID канала.
Без node_id — автоматический выбор лучшего достижимого узла.
С node_id — ссылка ведёт на указанный узел (hex, например 0x1234).
Ссылка включает pubkey, адреса выбранного узла и ID канала.
Пример: chatcli invite 12345
chatcli invite 12345 0x1a2b3c
invite_nodes <ch_id>
Список достижимых узлов канала, пригодных для invite (включая свой узел).
Ответ содержит auto_node_id — узел, выбранный авто-логикой,
и список nodes (node_id, имя).
Пример: chatcli invite_nodes 12345
connect <utun://...>
Подключиться к каналу по invite-ссылке.

200
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -19,6 +19,7 @@
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include "../../../src/chat/invite_build.h"
#include "../../../src/chat/chat_setting.h"
#include "../../../src/utun_instance.h"
#include "../../../src/transport_layer/etcp.h"
@ -229,202 +230,11 @@ void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id,
chat_sync_connect_from_invite(channel_id, real_node_id, pubkey_bin, addrs_data, addr_count, addrs_data_len, NULL);
}
/* Collect listening addresses from local sockets (for self-node fallback) */
static int bridge_collect_invite_addrs(struct InviteAddrC* out, int max_cnt) {
int cnt = 0;
struct ETCP_SOCKET* s = g_cc.inst->etcp_sockets;
while (s) {
if (s->is_tcp) { s = s->next; continue; }
struct sockaddr_storage* sa = s->interface_addr.ss_family ? &s->interface_addr : &s->local_addr;
if (sa && sa->ss_family == AF_INET && cnt < max_cnt) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa && sa->ss_family == AF_INET6 && cnt < max_cnt) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s->sock_id; out[cnt].proto = INVITE_PROTO_UDP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
s = s->next;
}
{ struct ETCP_SOCKET* s2 = g_cc.inst->etcp_sockets;
while (s2) {
if (!s2->is_tcp) { s2 = s2->next; continue; }
struct sockaddr_storage* sa = s2->interface_addr.ss_family ? &s2->interface_addr : &s2->local_addr;
if (sa && sa->ss_family == AF_INET && cnt < max_cnt) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
out[cnt].family = 4; out[cnt].socketId = s2->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin->sin_addr, 4);
out[cnt].port = ntohs(sin->sin_port); cnt++;
} else if (sa && sa->ss_family == AF_INET6 && cnt < max_cnt) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
out[cnt].family = 6; out[cnt].socketId = s2->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
s2 = s2->next; }
}
return cnt;
}
/* Collect addresses from node_addresses table for a remote node.
If dir_only=1 → only ADDR_TYPE_DIRECT, otherwise all addr_types */
static int bridge_collect_invite_addrs_from_db(sqlite3* db, uint64_t node_id,
struct InviteAddrC* out, int max_cnt, int dir_only) {
int cnt = 0;
char sql[256];
if (dir_only)
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses"
" WHERE node_id=? AND addr_type=%d", ADDR_TYPE_DIRECT);
else
snprintf(sql, sizeof(sql),
"SELECT family,socket_id,protocol,address,port FROM node_addresses"
" WHERE node_id=?");
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return 0;
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
while (sqlite3_step(st) == SQLITE_ROW && cnt < max_cnt) {
int family = sqlite3_column_int(st, 0);
out[cnt].family = family;
out[cnt].socketId = (uint8_t)sqlite3_column_int(st, 1);
out[cnt].proto = (uint8_t)sqlite3_column_int(st, 2);
const uint8_t* addr = sqlite3_column_blob(st, 3);
int addr_len = sqlite3_column_bytes(st, 3);
int ip_len = (family == 6) ? 16 : 4;
if (addr && addr_len >= ip_len) memcpy(out[cnt].address, addr, (size_t)ip_len);
else { memset(out[cnt].address, 0, sizeof(out[cnt].address)); }
out[cnt].port = (uint16_t)sqlite3_column_int(st, 4);
cnt++;
}
sqlite3_finalize(st);
return cnt;
}
char* utun_bridge_encode_invite_link(uint64_t channel_id, const char* password) {
if (!chat_core_is_initialized() || !g_cc.inst || !g_cc.db) {
bridge_log(BLEV_ERROR, "encode_invite_link: not initialized inst=%p db=%p",
(void*)g_cc.inst, g_cc.inst ? (void*)g_cc.db : NULL);
return NULL;
}
uint64_t my_nid = g_cc.my_node_id;
sqlite3* db = g_cc.db;
struct TOPO_GROUPS* groups = g_cc.inst->topo_groups;
/* ── Фаза 1: поиск лучшего узла в канале ── */
char ch_str[32]; snprintf(ch_str, sizeof(ch_str), "%llu", (unsigned long long)channel_id);
char peers_tbl[80]; peers_table_name(ch_str, peers_tbl, sizeof(peers_tbl));
uint64_t best_nid = 0;
int best_priority = 4; /* 1=regular, 2=self, 3=super, 4=invalid */
int best_has_both_public = 0;
sqlite3_stmt* pst = NULL;
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT node_id, node_type FROM \"%s\"", peers_tbl);
if (sqlite3_prepare_v2(db, sql, -1, &pst, NULL) == SQLITE_OK) {
while (sqlite3_step(pst) == SQLITE_ROW) {
uint64_t nid = (uint64_t)sqlite3_column_int64(pst, 0);
int ntype = sqlite3_column_int(pst, 1);
/* alive = BGP visible */
struct TOPO_GROUP* grp = groups ? topo_groups_find(groups, channel_id) : NULL;
if (!grp) continue;
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(grp, nid);
if (!nq || !(nq->conn_presence & (NCONN_DIRECT | NCONN_INDIRECT | NCONN_BGP)))
continue;
/* public v4+v6? */
int has_v4 = 0, has_v6 = 0;
sqlite3_stmt* ast = NULL;
snprintf(sql, sizeof(sql),
"SELECT family FROM node_addresses WHERE node_id=? AND addr_type=%d",
ADDR_TYPE_DIRECT);
if (sqlite3_prepare_v2(db, sql, -1, &ast, NULL) == SQLITE_OK) {
sqlite3_bind_int64(ast, 1, (sqlite3_int64)nid);
while (sqlite3_step(ast) == SQLITE_ROW) {
if (sqlite3_column_int(ast, 0) == 4) has_v4 = 1;
if (sqlite3_column_int(ast, 0) == 6) has_v6 = 1;
}
sqlite3_finalize(ast);
}
int has_both = (has_v4 && has_v6) ? 1 : 0;
/* priority */
int priority;
if (nid == my_nid) priority = 2; /* self */
else if (ntype == 4) priority = 3; /* supernode */
else priority = 1; /* regular */
/* pick best (lower priority, or same → later) */
if (priority <= best_priority) {
best_nid = nid; best_priority = priority;
best_has_both_public = has_both;
}
}
sqlite3_finalize(pst);
}
/* ── Фаза 2: fallback ── */
uint64_t target_nid = best_nid ? best_nid : my_nid;
int use_self = (target_nid == my_nid) ? 1 : 0;
bridge_log(BLEV_INFO, "encode_invite_link: ch=%llu best=0x%016llx prio=%d self=%d both=%d tot_peer_rows=%d",
(unsigned long long)channel_id, (unsigned long long)target_nid,
best_priority, use_self, best_has_both_public,
best_nid ? 1 : 0);
/* ── Фаза 3: pubkey ── */
struct InviteDataC data; memset(&data, 0, sizeof(data));
data.channelId = channel_id; data.nodeId = target_nid;
if (use_self) {
memcpy(data.pubkey, g_cc.inst->my_keys.public_key, INVITE_PUBKEY_SIZE);
} else {
sqlite3_stmt* pkst = NULL;
snprintf(sql, sizeof(sql), "SELECT x25519_pubkey FROM nodes WHERE node_id=?");
if (sqlite3_prepare_v2(db, sql, -1, &pkst, NULL) == SQLITE_OK) {
sqlite3_bind_int64(pkst, 1, (sqlite3_int64)target_nid);
if (sqlite3_step(pkst) == SQLITE_ROW) {
const void* pk = sqlite3_column_blob(pkst, 0);
int pklen = sqlite3_column_bytes(pkst, 0);
if (pk && pklen >= 32) memcpy(data.pubkey, pk, INVITE_PUBKEY_SIZE);
}
sqlite3_finalize(pkst);
}
if (data.pubkey[0] == 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: no pubkey for selected node=0x%016llx",
(unsigned long long)target_nid);
return NULL;
}
}
/* ── Фаза 4: адреса ── */
if (use_self) {
data.addrCount = (uint8_t)bridge_collect_invite_addrs(data.addrs, INVITE_ADDR_MAX);
} else {
int dir_only = best_has_both_public ? 1 : 0;
data.addrCount = (uint8_t)bridge_collect_invite_addrs_from_db(
db, target_nid, data.addrs, INVITE_ADDR_MAX, dir_only);
}
if (data.addrCount == 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: no addrs for node=0x%016llx",
(unsigned long long)target_nid);
return NULL;
}
bridge_log(BLEV_INFO, "encode_invite_link: node=0x%016llx addrs=%d dir_only=%d",
(unsigned long long)target_nid, data.addrCount,
(!use_self && best_has_both_public) ? 1 : 0);
/* ── Фаза 5: encode ── */
char out[512];
int r = invite_link_encode(&data, password, out, sizeof(out));
if (r < 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: encode failed r=%d", r);
char out[1024];
if (chat_invite_build_link(channel_id, 0, password, out, sizeof(out)) < 0) {
bridge_log(BLEV_ERROR, "encode_invite_link: chat_invite_build_link failed ch=%llu",
(unsigned long long)channel_id);
return NULL;
}
return u_strdup(out);

167
tools/chatgui/src/invitedialog.cpp

@ -1,15 +1,29 @@
#include "invitedialog.h"
#include "qrcode_utils.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QComboBox>
#include <QClipboard>
#include <QApplication>
#include <QSignalBlocker>
#include <cstring>
extern "C" {
#include "../../../src/chat/invite_build.h"
#include "../../../lib/mem.h"
}
ShareInviteDialog::ShareInviteDialog(const QString& link, QWidget* parent)
: QDialog(parent), m_link(link)
ShareInviteDialog::ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent)
: QDialog(parent)
, m_channelId(channelId)
, m_db(db)
{
setWindowTitle(QString::fromUtf8("Пригласительная ссылка"));
setMinimumWidth(420);
@ -18,33 +32,152 @@ ShareInviteDialog::ShareInviteDialog(const QString& link, QWidget* parent)
auto *layout = new QVBoxLayout(this);
layout->setSpacing(12);
auto *linkEdit = new QLineEdit(m_link, this);
linkEdit->setReadOnly(true);
linkEdit->setStyleSheet("padding: 6px; font-family: monospace;");
layout->addWidget(linkEdit);
auto *nodeLabel = new QLabel(QString::fromUtf8("Узел входа:"), this);
nodeLabel->setStyleSheet("font-size: 12px; color: palette(window-text);");
layout->addWidget(nodeLabel);
m_nodeCombo = new QComboBox(this);
m_nodeCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
layout->addWidget(m_nodeCombo);
m_linkEdit = new QLineEdit(this);
m_linkEdit->setReadOnly(true);
m_linkEdit->setPlaceholderText(QString::fromUtf8("Генерация ссылки..."));
m_linkEdit->setStyleSheet("padding: 6px; font-family: monospace;");
layout->addWidget(m_linkEdit);
auto *btnLayout = new QHBoxLayout();
auto *copyBtn = new QPushButton(QString::fromUtf8("Копировать"), this);
connect(copyBtn, &QPushButton::clicked, this, [this, linkEdit]() {
connect(copyBtn, &QPushButton::clicked, this, [this]() {
QClipboard* cb = QApplication::clipboard();
cb->setText(m_link, QClipboard::Clipboard);
cb->setText(m_linkEdit->text(), QClipboard::Clipboard);
if (cb->supportsSelection())
cb->setText(m_link, QClipboard::Selection);
linkEdit->setFocus();
cb->setText(m_linkEdit->text(), QClipboard::Selection);
m_linkEdit->setFocus();
});
btnLayout->addWidget(copyBtn);
btnLayout->addStretch();
layout->addLayout(btnLayout);
auto *qrLabel = new QLabel(this);
QImage qrImage = generateQRCode(m_link);
if (!qrImage.isNull())
qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled(
180, 180, Qt::KeepAspectRatio, Qt::SmoothTransformation));
qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(qrLabel, 0, Qt::AlignCenter);
m_qrLabel = new QLabel(this);
m_qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_qrLabel, 0, Qt::AlignCenter);
auto *closeBtn = new QPushButton("OK", this);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept);
layout->addWidget(closeBtn, 0, Qt::AlignRight);
connect(m_nodeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ShareInviteDialog::onNodeSelected);
/* колбэки результатов (по образцу JoinDialog) */
gui_bridge_set_invite_link_ready_cb([](const char* chId, int chIdLen, const char* link) {
ShareInviteDialog* dlg = qobject_cast<ShareInviteDialog*>(QApplication::activeModalWidget());
if (dlg) dlg->onLinkReady(chId, chIdLen, link);
});
gui_bridge_set_invite_candidates_cb([](const char* chId, int chIdLen, const uint8_t* data, int len) {
ShareInviteDialog* dlg = qobject_cast<ShareInviteDialog*>(QApplication::activeModalWidget());
if (dlg) dlg->onCandidates(chId, chIdLen, data, len);
});
/* запросить кандидатов; линк строится после их прихода */
QByteArray chBytes = m_channelId.toUtf8();
gui_bridge_post_uasync_fn(chat_invite_candidates_trampoline, u_strdup(chBytes.constData()));
}
ShareInviteDialog::~ShareInviteDialog() {
gui_bridge_set_invite_link_ready_cb(nullptr);
gui_bridge_set_invite_candidates_cb(nullptr);
}
void ShareInviteDialog::onNodeSelected(int index) {
if (index < 0) return;
qulonglong nodeId = m_nodeCombo->itemData(index).toULongLong();
requestLink((uint64_t)nodeId);
}
void ShareInviteDialog::requestLink(uint64_t targetNodeId) {
struct chat_invite_build_req* req = (struct chat_invite_build_req*)u_malloc(sizeof(*req));
if (!req) {
GUI_ERROR("invite: requestLink — alloc failed");
return;
}
memset(req, 0, sizeof(*req));
QByteArray chBytes = m_channelId.toUtf8();
strncpy(req->ch_id, chBytes.constData(), sizeof(req->ch_id) - 1);
req->target_node_id = targetNodeId;
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Генерация ссылки..."));
m_qrLabel->clear();
gui_bridge_post_uasync_fn(chat_invite_build_link_trampoline, req);
}
void ShareInviteDialog::setLink(const QString& link) {
m_linkEdit->setText(link);
QImage qrImage = generateQRCode(link);
if (!qrImage.isNull())
m_qrLabel->setPixmap(QPixmap::fromImage(qrImage).scaled(
180, 180, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
void ShareInviteDialog::onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len) {
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!data || len < 10) return;
uint64_t autoId = 0;
memcpy(&autoId, data, 8);
uint16_t count = 0;
memcpy(&count, data + 8, 2);
if (autoId == 0) {
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Не удалось определить узел (см. лог)"));
return;
}
auto nodeLabel = [this](uint64_t nid) {
QString name = m_db ? m_db->getDisplayName(nid) : QString();
if (name.isEmpty())
name = QString("0x%1").arg(nid, 16, 16, QChar('0'));
return name;
};
/* блокируем сигналы, чтобы не перезапрашивать линк при добавлении пунктов */
QSignalBlocker blocker(m_nodeCombo);
m_nodeCombo->clear();
/* пункт 0 — узел, найденный авто-выбором */
m_nodeCombo->addItem(nodeLabel(autoId) + QString::fromUtf8(" (auto)"),
QVariant::fromValue((qulonglong)autoId));
/* остальные узлы (пропуская авто), свой помечаем (self) */
int off = 10;
for (int i = 0; i < (int)count && off + 8 <= len; i++) {
uint64_t nid = 0;
memcpy(&nid, data + off, 8);
off += 8;
if (nid == autoId) continue;
QString label = nodeLabel(nid);
if (m_db && nid == m_db->myNodeId())
label += QString::fromUtf8(" (self)");
m_nodeCombo->addItem(label, QVariant::fromValue((qulonglong)nid));
}
/* строим линк для авто-узла (сигнал заблокирован) */
requestLink(autoId);
}
void ShareInviteDialog::onLinkReady(const char* chId, int chIdLen, const char* link) {
QString cid = QString::fromUtf8(chId, chIdLen);
if (cid != m_channelId) return;
if (!link || link[0] == '\0') {
m_linkEdit->setText(QString());
m_linkEdit->setPlaceholderText(QString::fromUtf8("Не удалось собрать ссылку (см. лог)"));
m_qrLabel->clear();
return;
}
setLink(QString::fromUtf8(link));
}

23
tools/chatgui/src/invitedialog.h

@ -2,12 +2,31 @@
#include <QDialog>
#include <QString>
#include <stdint.h>
class DbManager;
class QComboBox;
class QLineEdit;
class QLabel;
class ShareInviteDialog : public QDialog {
Q_OBJECT
public:
explicit ShareInviteDialog(const QString& link, QWidget* parent = nullptr);
explicit ShareInviteDialog(const QString& channelId, DbManager* db, QWidget* parent = nullptr);
~ShareInviteDialog();
private slots:
void onNodeSelected(int index);
private:
QString m_link;
void requestLink(uint64_t targetNodeId);
void setLink(const QString& link);
void onCandidates(const char* chId, int chIdLen, const uint8_t* data, int len);
void onLinkReady(const char* chId, int chIdLen, const char* link);
QString m_channelId;
DbManager* m_db;
QComboBox* m_nodeCombo;
QLineEdit* m_linkEdit;
QLabel* m_qrLabel;
};

19
tools/chatgui/src/mainwindow.cpp

@ -430,24 +430,7 @@ void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) {
void MainWindow::showShareInviteDialog(const QString& channelId) {
if (!m_db || !m_db->isOpen()) return;
QByteArray pubkey;
if (m_node && m_node->isRunning())
pubkey = QByteArray::fromHex(m_node->pubKeyHex().toLatin1());
if (pubkey.size() != 32) {
QMessageBox::warning(this, "Error", "Failed to get node public key.");
return;
}
QList<NodeAddr> addrs = m_node->getInviteAddresses(m_db);
QString link = encodeInviteLink(channelId.toULongLong(), pubkey, addrs);
if (link.isEmpty()) {
QMessageBox::warning(this, "Error", "Failed to create invite link.");
return;
}
ShareInviteDialog dlg(link, this);
ShareInviteDialog dlg(channelId, m_db, this);
dlg.exec();
}

90
tools/chatgui/src/messagelist.cpp

@ -488,40 +488,6 @@ void MessageList::refresh() {
m_readDebounce->start();
}
void MessageList::addMessage(const QString& channelId, quint64 authorNodeId,
const QByteArray& content, qint64 timestamp) {
if (channelId != m_currentChannelId) return;
AccountRow acc = m_db ? m_db->getAccount(authorNodeId) : AccountRow();
QString author = m_db ? authorName(authorNodeId) : QString("node_%1").arg(authorNodeId);
QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
auto avatar = makeAvatar(color, letter);
auto time = QDateTime::fromMSecsSinceEpoch(timestamp).toString("HH:mm");
/* dedup */
for (int r = 0; r < m_model->rowCount(); r++) {
auto* it = m_model->item(r);
if (it->data(MsgTimeRole).toString() == time
&& it->data(MsgAuthorRole).toString() == author) return;
}
auto *item = new QStandardItem();
setMsg(item, author, QString::fromUtf8(content), time, avatar, false,
false, QString(), QString(), QVariantList{});
bool isSelf = m_db && (authorNodeId == m_db->myNodeId());
item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole);
item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole);
setVoiceMessageRoles(item, content, QString(),
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, QByteArray());
m_model->appendRow(item);
if (m_model->rowCount() > 50)
m_model->removeRow(0);
m_view->scrollToBottom();
}
void MessageList::saveChannelState() {
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return;
@ -679,44 +645,36 @@ void MessageList::updateMessageProgress(const QString& chId, int64_t msgId,
}
void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) {
QString ct;
QString filePath;
if (chId != m_currentChannelId || !m_db || !m_db->isOpen()) return;
MessageRow m = m_db->getMessageById(chId, msgId);
if (m.id == 0) return;
int row = -1;
bool wasPending = false;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* item = m_model->item(r);
if (!item) continue;
if (item->data(MsgIdRole).toLongLong() == msgId) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
row = r;
item->setData(2, MsgFileDownloadStateRole);
item->setData(0, MsgMediaBlocksDoneRole);
ct = item->data(MsgContentTypeRole).toString();
filePath = item->data(MsgVoiceFileRole).toString();
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool();
break;
}
}
if (row >= 0 && chId == m_currentChannelId) {
bool wasPending = false;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool();
if (wasPending) it->setData(false, MsgVoiceDownloadPendingRole);
break;
}
}
refresh(); /* reload to get updated local_attrs with file path */
/* auto-play only if user clicked play to initiate download */
if (ct == "audio/opus" && wasPending) {
QTimer::singleShot(500, this, [this, msgId]() {
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
QString fp = it->data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp))
VoicePlayer::playOpusFile(fp);
break;
}
}
if (row < 0) return;
QStandardItem* item = m_model->item(row);
setVoiceMessageRoles(item, m.data, m.contentType,
m_db->dbPath() + "/media", chId, m.localAttrs);
item->setData(0, MsgMediaBlocksDoneRole);
item->setData(false, MsgVoiceDownloadPendingRole);
if (m.contentType == "audio/opus" && wasPending) {
QString fp = item->data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp)) {
QTimer::singleShot(500, this, [this, fp]() {
MessageDelegate::stopAllAutoPlay();
float actualDur = VoicePlayer::playOpusFile(fp);
MessageDelegate::startAutoPlay(fp, actualDur);
});
}
}

2
tools/chatgui/src/messagelist.h

@ -24,8 +24,6 @@ public:
void reloadLocalNicks();
void saveChannelState();
static void cleanupAnimations();
void addMessage(const QString& channelId, quint64 authorNodeId,
const QByteArray& content, qint64 timestamp);
void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks);
void onAttachmentDownloaded(const QString& chId, int64_t msgId);

21
tools/chatgui/src/sound_manager.cpp

@ -32,10 +32,19 @@ bool SoundManager::init() {
return true;
}
void SoundManager::clearCurrentPcm() {
if (m_currentPcmSound) { ma_sound_uninit(m_currentPcmSound); delete m_currentPcmSound; m_currentPcmSound = nullptr; }
if (m_currentPcmBuffer) {
ma_audio_buffer* buf = (ma_audio_buffer*)m_currentPcmBuffer;
ma_audio_buffer_uninit(buf); delete buf; m_currentPcmBuffer = nullptr;
}
m_currentPcmSampleRate = 0;
}
void SoundManager::shutdown() {
if (!m_initialized) return;
stopCurrentPcm();
clearCurrentPcm();
for (auto it = m_sounds.begin(); it != m_sounds.end(); ++it) {
if (it->sound) { ma_sound_uninit(it->sound); delete it->sound; }
@ -45,11 +54,6 @@ void SoundManager::shutdown() {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "SoundManager: before ma_engine_uninit engine=%p", (void*)m_engine);
if (m_engine) { ma_engine_uninit(m_engine); delete m_engine; m_engine = nullptr; }
if (m_currentPcmSound) { delete m_currentPcmSound; m_currentPcmSound = nullptr; }
if (m_currentPcmBuffer) {
ma_audio_buffer* buf = (ma_audio_buffer*)m_currentPcmBuffer;
ma_audio_buffer_uninit(buf); delete buf; m_currentPcmBuffer = nullptr;
}
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "SoundManager: after ma_engine_uninit");
m_initialized = false;
}
@ -152,7 +156,7 @@ void SoundManager::playRawPcm(const int16_t* pcm, int sampleCount, int sampleRat
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::playRawPcm: not initialized");
return;
}
if (m_currentPcmSound) ma_sound_stop(m_currentPcmSound);
clearCurrentPcm();
m_currentPcmSampleRate = sampleRate;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "SoundManager::playRawPcm: %d samples %dHz %dch", sampleCount, sampleRate, channels);
@ -189,8 +193,7 @@ void SoundManager::resumeCurrentPcm() {
}
void SoundManager::stopCurrentPcm() {
if (m_currentPcmSound) ma_sound_stop(m_currentPcmSound);
m_currentPcmSampleRate = 0;
clearCurrentPcm();
}
unsigned long long SoundManager::currentPcmCursor() const {

2
tools/chatgui/src/sound_manager.h

@ -58,6 +58,8 @@ private:
SoundManager() = default;
~SoundManager();
void clearCurrentPcm();
struct LoadedSound {
QByteArray mp3Data;
ma_decoder* decoder = nullptr;

10
tools/chatgui/transport/gui_bridge.h

@ -36,6 +36,8 @@ struct TOPO_GROUP_NODE;
#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] — получен канальный ключ */
#define GUI_EVT_INVITE_LINK_READY 29 /* data: [ch_id_len:1][ch_id:var][link_len:2][link:var] — пустой link = ошибка */
#define GUI_EVT_INVITE_CANDIDATES 30 /* data: [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */
/* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */
#define MEMBER_DETAIL_LINK_SIZE 63
@ -147,6 +149,14 @@ void gui_bridge_set_bgp_info_cb(gui_bgp_info_fn cb);
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);
/* Callback: готова invite-ссылка (link = пустая строка при ошибке, детали в логе) */
typedef void (*gui_invite_link_ready_fn)(const char* ch_id, int ch_id_len, const char* link);
void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb);
/* Callback: список достижимых узлов-кандидатов для invite — data: [auto_node_id:8][count:2][node_id:8]* */
typedef void (*gui_invite_candidates_fn)(const char* ch_id, int ch_id_len, const uint8_t* data, int len);
void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_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);

33
tools/chatgui/transport/gui_bridge_impl.cpp

@ -46,6 +46,8 @@ 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 gui_invite_link_ready_fn g_invite_link_ready_cb = nullptr;
static gui_invite_candidates_fn g_invite_candidates_cb = nullptr;
static struct UASYNC* g_ua = nullptr;
/* ── GuiBridgeReceiver implementation ── */
@ -210,6 +212,29 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
}
}
break;
case GUI_EVT_INVITE_LINK_READY:
if (dlen >= 3) {
uint8_t chLen = d[0];
uint16_t linkLen = 0; memcpy(&linkLen, d + 1 + chLen, 2);
if (dlen >= 1 + chLen + 2 + linkLen) {
if (linkLen == 0) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: INVITE_LINK_READY ch=%.*s — empty link (build failed)",
chLen, (const char*)d + 1);
if (g_invite_link_ready_cb) g_invite_link_ready_cb((const char*)d + 1, chLen, "");
} else {
if (g_invite_link_ready_cb) g_invite_link_ready_cb((const char*)d + 1, chLen, (const char*)(d + 1 + chLen + 2));
}
}
}
break;
case GUI_EVT_INVITE_CANDIDATES:
if (dlen >= 2) {
uint8_t chLen = d[0];
if (dlen >= 1 + chLen + 10) {
if (g_invite_candidates_cb) g_invite_candidates_cb((const char*)d + 1, chLen, d + 1 + chLen, dlen - 1 - chLen);
}
}
break;
default:
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType);
break;
@ -335,6 +360,14 @@ void gui_bridge_set_admin_key_received_cb(gui_admin_key_received_fn cb) {
g_admin_key_received_cb = cb;
}
void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb) {
g_invite_link_ready_cb = cb;
}
void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb) {
g_invite_candidates_cb = cb;
}
} /* extern "C" */
/* ── uTun nodeinfo callback (called from uasync thread) ── */

115
tools/chatgui/transport/utun_node.cpp

@ -2,14 +2,9 @@
#include "utun_node.h"
#include <cinttypes>
#include <QDir>
#include <QMap>
#include <QPair>
#include <csignal>
#include "../../lib/socket_compat.h"
#include "../db/db_manager.h"
#include "../src/invite_link.h"
// === C headers ===
extern "C" {
#include "utun_instance.h"
@ -139,116 +134,6 @@ bool UtunNode::ntpEnabled() const {
return m_instance ? m_instance->ntp.enabled : false;
}
QList<NodeAddr> UtunNode::getInviteAddresses(DbManager* db) {
(void)db;
QList<NodeAddr> addrs;
QMap<QPair<QByteArray, quint16>, uint8_t> addrProto; // key=(addr,port) → proto bits
auto add_addr = [&](const QByteArray& addr, quint16 port, int family,
int sockId, uint8_t proto) {
QPair<QByteArray, quint16> key(addr, port);
uint8_t existing = addrProto.value(key, 0);
addrProto[key] = existing | proto;
// Store sockId/family for serialization (overwrite is fine — same port)
};
auto append_from_addr_proto = [&](const QByteArray& addr, quint16 port,
int family, int sockId, uint8_t proto) {
NodeAddr a;
a.family = family;
a.address = addr;
a.port = port;
a.socketId = sockId;
a.protocol = proto;
addrs.append(a);
};
if (m_instance) {
/* 1) UDP from active ETCP links */
struct ll_entry* entry = m_instance->connections->head;
while (entry) {
struct conn_queue_entry* ce = (struct conn_queue_entry*)entry->data;
struct ETCP_CONN* conn = ce->conn;
if (conn && conn->initialized && conn->links_up > 0) {
struct ETCP_LINK* link = conn->links;
while (link) {
if (!link->is_tcp && link->link_state == 3 && link->nat_type == NAT_TYPE_DIRECT) {
struct sockaddr_storage* sa = &link->remote_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4,
link->conn ? (int)link->conn->sock_id : 0, INVITE_PROTO_UDP);
} else {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6,
link->conn ? (int)link->conn->sock_id : 0, INVITE_PROTO_UDP);
}
}
link = link->next;
}
}
entry = entry->next;
}
/* 2) UDP from local ETCP sockets (fallback) */
{
struct ETCP_SOCKET* sock = m_instance->etcp_sockets;
while (sock) {
if (sock->is_tcp) { sock = sock->next; continue; }
struct sockaddr_storage* sa = NULL;
if (sock->nat_addr.ss_family != 0)
sa = &sock->nat_addr;
else if (sock->interface_addr.ss_family != 0)
sa = &sock->interface_addr;
if (sa) {
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4, (int)sock->sock_id, INVITE_PROTO_UDP);
} else {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6, (int)sock->sock_id, INVITE_PROTO_UDP);
}
}
sock = sock->next;
}
}
/* 3) TCP from local TCP sockets */
{
struct ETCP_SOCKET* s = m_instance->etcp_sockets;
while (s) {
if (!s->is_tcp) { s = s->next; continue; }
struct sockaddr_storage* sa = &s->interface_addr;
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
QByteArray addr((const char*)&sin->sin_addr, 4);
add_addr(addr, ntohs(sin->sin_port), 4, (int)s->sock_id, INVITE_PROTO_TCP);
} else if (sa->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
QByteArray addr((const char*)&sin6->sin6_addr, 16);
add_addr(addr, ntohs(sin6->sin6_port), 6, (int)s->sock_id, INVITE_PROTO_TCP);
}
s = s->next;
}
}
/* 4) Dedup by (addr,port); emit one entry per proto (UDP и TCP раздельно, без BOTH) */
for (auto it = addrProto.begin(); it != addrProto.end(); ++it) {
QByteArray addr = it.key().first;
quint16 port = it.key().second;
uint8_t proto = it.value();
int family = (addr.size() == 16) ? 6 : 4;
if (proto & INVITE_PROTO_UDP) append_from_addr_proto(addr, port, family, 0, INVITE_PROTO_UDP);
if (proto & INVITE_PROTO_TCP) append_from_addr_proto(addr, port, family, 0, INVITE_PROTO_TCP);
}
}
return addrs;
}
void UtunNode::recvCallback(struct ETCP_CONN* conn, struct ll_entry* entry) {
if (!g_currentNode || !entry || !entry->dgram || entry->len < ROUTER_SVC_HDR_SIZE) return;
uint64_t src;

4
tools/chatgui/transport/utun_node.h

@ -5,14 +5,11 @@
#include <QObject>
#include <QByteArray>
#include <QString>
#include <QList>
#include <atomic>
#include <cstdint>
#include <thread>
struct UTUN_INSTANCE;
class DbManager;
struct NodeAddr;
class UtunNode : public QObject {
Q_OBJECT
@ -37,7 +34,6 @@ public:
QString nodeIdHex() const;
QString pubKeyHex() const;
QList<NodeAddr> getInviteAddresses(DbManager* db);
int64_t ntpOffsetUs() const;
bool ntpSynced() const;
bool ntpEnabled() const;

Loading…
Cancel
Save