evgeny 2 months ago
parent
commit
712d1b53df
  1. 16
      111.txt
  2. 1137
      ARCHITECTURE.md
  3. 1
      src/chat/chat_event.h
  4. 1
      src/chat/chat_setting.c
  5. 282
      src/chat/chat_sync.c
  6. 17
      src/chat/chat_sync.h
  7. 170
      src/config_parser.c
  8. 8
      src/config_parser.h
  9. 1
      src/utun.c
  10. 1
      tools/chatgui-android/app/build.gradle.kts
  11. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  12. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  13. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  14. 2
      tools/chatgui-android/headless/headless_control.c
  15. 216
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  16. 3
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  17. 36
      tools/chatgui-android/libutun_lite/invite_link_c.c
  18. 8
      tools/chatgui-android/libutun_lite/invite_link_c.h
  19. 38
      tools/chatgui/src/invite_link.cpp
  20. 4
      tools/chatgui/src/invite_link.h
  21. 123
      tools/chatgui/src/invitedialog.cpp
  22. 23
      tools/chatgui/src/invitedialog.h
  23. 3
      tools/chatgui/src/joindialog.cpp
  24. 2
      tools/chatgui/src/mainwindow.cpp
  25. 29
      tools/chatgui/src/settingsdialog.cpp
  26. 1
      tools/chatgui/src/settingsdialog.h
  27. 5
      tools/chatgui/transport/gui_bridge.h
  28. 8
      tools/chatgui/transport/gui_bridge_impl.cpp
  29. 7
      utun.conf.sample

16
111.txt

@ -1,16 +0,0 @@
было:
Node: 0x32a3a1ee5625ceb9 (chatgui)
online: no
x25519: 33 2A 8D A2..FC 87 00 3F
ed25519: B4 94 9F 4A..5D 9D A9 FB
last_seen: 2026-07-26 07:19:18
created: 2026-07-23 13:14:13
addresses:
192.168.40.200:1333 UDP NETIF rtt=6ms
стало:
ID:0x32a3a1ee5625ceb9
[chatgui] * <- зеленая или красная точка если онлайн
[иконка last seen] 1h (5s с интервалом 5 сек, 1m 1h 1d)
[icon created] 20d (дней назад)
UDP:192.168.40.200:1333

1137
ARCHITECTURE.md

File diff suppressed because it is too large Load Diff

1
src/chat/chat_event.h

@ -36,6 +36,7 @@ extern "C" {
#define CHAT_EVT_DOWNLOAD_PROGRESS 15 /* [ch_id_len:1][ch_id:var][msg_id:8][blocks_done:4][num_blocks:4] */ #define CHAT_EVT_DOWNLOAD_PROGRESS 15 /* [ch_id_len:1][ch_id:var][msg_id:8][blocks_done:4][num_blocks:4] */
#define CHAT_EVT_NODEINFO_UPDATED 16 /* [node_id:8][conn_presence:1][conn_up:1][best_rtt:2] */ #define CHAT_EVT_NODEINFO_UPDATED 16 /* [node_id:8][conn_presence:1][conn_up:1][best_rtt:2] */
#define CHAT_EVT_LINK_STATUS_CHANGED 17 /* [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */ #define CHAT_EVT_LINK_STATUS_CHANGED 17 /* [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */
#define CHAT_EVT_INVITE_RECEIVED 18 /* [ch_id_len:1][ch_id][name_len:1][name][inviter_node_id:8][inviter_name_len:1][inviter_name] */
typedef void (*chat_event_handler_fn)(int type, const uint8_t* data, int len); typedef void (*chat_event_handler_fn)(int type, const uint8_t* data, int len);

1
src/chat/chat_setting.c

@ -42,6 +42,7 @@ static const struct chat_setting_def g_setting_defs[] = {
{"compressor_max_gain_db", CHAT_SETTING_INT, 25, 1, 100}, {"compressor_max_gain_db", CHAT_SETTING_INT, 25, 1, 100},
{"compressor_rise_rate", CHAT_SETTING_INT, 10, 1, 100}, {"compressor_rise_rate", CHAT_SETTING_INT, 10, 1, 100},
{"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16},
{"join_policy", CHAT_SETTING_INT, 1, 0, 2}, /* 0=autojoin, 1=ask, 2=deny */
}; };
#define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) #define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0]))

282
src/chat/chat_sync.c

@ -1,5 +1,6 @@
#include "chat_sync.h" #include "chat_sync.h"
#include "chat_core.h" #include "chat_core.h"
#include "chat_setting.h"
#include "chat_event.h" #include "chat_event.h"
#include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_node_sqlite.h"
#include "../routing_layer/topo_group.h" #include "../routing_layer/topo_group.h"
@ -50,6 +51,11 @@ struct chat_sync {
uint8_t sync_scheduled; uint8_t sync_scheduled;
uint64_t pending_invite_ch_id; uint64_t pending_invite_ch_id;
uint64_t pending_invite_node_id; uint64_t pending_invite_node_id;
char pending_password[128]; /* пароль для CHANNEL_INFO_REQ */
/* Pending incoming invites (для ask/deny) */
uint64_t pending_inv_in_ch_id[4];
uint64_t pending_inv_in_node_id[4];
int pending_inv_in_count;
}; };
#define CS_ID "chat_sync" #define CS_ID "chat_sync"
@ -72,6 +78,7 @@ static const char* cs_msg_name(uint8_t type) {
case CS_MSG_PEER_UPSERT: return "PEER_UPSERT"; case CS_MSG_PEER_UPSERT: return "PEER_UPSERT";
case CS_MSG_ERROR: return "ERROR"; case CS_MSG_ERROR: return "ERROR";
case CS_MSG_PEER_REMOVE: return "PEER_REMOVE"; case CS_MSG_PEER_REMOVE: return "PEER_REMOVE";
case CS_MSG_CHANNEL_INVITE: return "CHANNEL_INVITE";
default: return "???"; default: return "???";
} }
} }
@ -136,6 +143,8 @@ static void cs_handle_peer_upsert(struct chat_sync* cs, uint64_t peer,
const char* ch_id, const uint8_t* pl, size_t len); const char* ch_id, const uint8_t* pl, size_t len);
static void cs_handle_peer_remove(struct chat_sync* cs, uint64_t peer, static void cs_handle_peer_remove(struct chat_sync* cs, uint64_t peer,
const char* ch_id, const uint8_t* pl, size_t len); const char* ch_id, const uint8_t* pl, size_t len);
static void cs_handle_channel_invite(struct chat_sync* cs, uint64_t peer,
const char* ch_id, const uint8_t* pl, size_t len);
/* ── Recv dispatcher ── */ /* ── Recv dispatcher ── */
@ -178,6 +187,7 @@ static void chat_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) {
break; break;
} }
case CS_MSG_PEER_REMOVE: cs_handle_peer_remove(g_cs, peer, ch_id, pl, plen); break; case CS_MSG_PEER_REMOVE: cs_handle_peer_remove(g_cs, peer, ch_id, pl, plen); break;
case CS_MSG_CHANNEL_INVITE: cs_handle_channel_invite(g_cs, peer, ch_id, pl, plen); break;
default: DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: UNKNOWN msg type=%02x from=%016llx", CS_ID, type, (unsigned long long)peer); break; default: DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: UNKNOWN msg type=%02x from=%016llx", CS_ID, type, (unsigned long long)peer); break;
} }
u_free(entry->dgram); queue_entry_free(entry); u_free(entry->dgram); queue_entry_free(entry);
@ -405,12 +415,16 @@ static void cs_start_channel_join(struct chat_sync* cs, uint64_t peer) {
if (!cs || cs->info_req_timer) return; if (!cs || cs->info_req_timer) return;
char ch_id_str[64]; char ch_id_str[64];
snprintf(ch_id_str, sizeof(ch_id_str), "%llu", (unsigned long long)cs->pending_invite_ch_id); snprintf(ch_id_str, sizeof(ch_id_str), "%llu", (unsigned long long)cs->pending_invite_ch_id);
uint8_t req[1] = { CS_MSG_CHANNEL_INFO_REQ }; size_t plen = strlen(cs->pending_password);
cs_send(cs, ch_id_str, peer, req, 1); uint8_t req[130];
req[0] = CS_MSG_CHANNEL_INFO_REQ;
req[1] = (uint8_t)(plen > 127 ? 127 : plen);
if (plen > 0) memcpy(req + 2, cs->pending_password, plen > 127 ? 127 : plen);
cs_send(cs, ch_id_str, peer, req, 2 + (plen > 127 ? 127 : (int)plen));
cs->info_req_timer = uasync_set_timeout(cs->inst->ua, cs->info_req_timer = uasync_set_timeout(cs->inst->ua,
CS_INFO_REQ_TIMEOUT_MS * 10, cs, cs_info_req_timeout_cb, "cs_info_req"); CS_INFO_REQ_TIMEOUT_MS * 10, cs, cs_info_req_timeout_cb, "cs_info_req");
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: sent CHANNEL_INFO_REQ ch=%llu peer=0x%016llx", DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: sent CHANNEL_INFO_REQ ch=%llu peer=0x%016llx pass_len=%zu",
CS_ID, (unsigned long long)cs->pending_invite_ch_id, (unsigned long long)peer); CS_ID, (unsigned long long)cs->pending_invite_ch_id, (unsigned long long)peer, plen);
} }
/* ── Connection callbacks ── */ /* ── Connection callbacks ── */
@ -695,7 +709,8 @@ static void cm_invite_trampoline(void* arg) {
void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id, void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id,
const uint8_t* pubkey_bin, const uint8_t* pubkey_bin,
const uint8_t* addrs_data, int addr_count, const uint8_t* addrs_data, int addr_count,
int addrs_data_len) { int addrs_data_len,
const char* password) {
if (!g_cs || !g_cs->inst || !g_cs->inst->ua) { if (!g_cs || !g_cs->inst || !g_cs->inst->ua) {
int r = -7; int r = -7;
#ifdef __ANDROID__ #ifdef __ANDROID__
@ -724,13 +739,21 @@ void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id,
g_cs->pending_invite_ch_id = channel_id; g_cs->pending_invite_ch_id = channel_id;
g_cs->pending_invite_node_id = node_id; g_cs->pending_invite_node_id = node_id;
if (password && password[0]) {
size_t pl = strlen(password);
if (pl >= sizeof(g_cs->pending_password)) pl = sizeof(g_cs->pending_password) - 1;
memcpy(g_cs->pending_password, password, pl);
g_cs->pending_password[pl] = '\0';
} else {
g_cs->pending_password[0] = '\0';
}
#ifdef __ANDROID__ #ifdef __ANDROID__
__android_log_print(ANDROID_LOG_INFO, "utun-chat", "invite ch=%llu node=%016llx addrs=%d", __android_log_print(ANDROID_LOG_INFO, "utun-chat", "invite ch=%llu node=%016llx addrs=%d pass=%s",
(long long)channel_id, (long long)node_id, addr_count); (long long)channel_id, (long long)node_id, addr_count, password ? "yes" : "no");
#endif #endif
#ifndef __ANDROID__ #ifndef __ANDROID__
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite start ch=%llu node=0x%016llx pubkey=%016llx... addrs=%d", DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite start ch=%llu node=0x%016llx pubkey=%016llx... addrs=%d pass=%s",
CS_ID, channel_id, node_id, *(const uint64_t*)pubkey_bin, addr_count); CS_ID, channel_id, node_id, *(const uint64_t*)pubkey_bin, addr_count, password && password[0] ? "yes" : "no");
#endif #endif
struct cm_invite_wrap { struct chat_invite inv; }* w = u_malloc(sizeof(struct cm_invite_wrap)); struct cm_invite_wrap { struct chat_invite inv; }* w = u_malloc(sizeof(struct cm_invite_wrap));
@ -825,8 +848,32 @@ static int _get_node_name(sqlite3* db, uint64_t node_id, char* out, size_t sz) {
static void cs_handle_channel_info_req(struct chat_sync* cs, uint64_t peer, static void cs_handle_channel_info_req(struct chat_sync* cs, uint64_t peer,
const char* ch_id, const uint8_t* pl, size_t len) { const char* ch_id, const uint8_t* pl, size_t len) {
(void)pl; (void)len; DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "%s: INFO_REQ ch=%s from=%016llx len=%zu", CS_ID, ch_id, (unsigned long long)peer, len);
DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "%s: INFO_REQ ch=%s from=%016llx", CS_ID, ch_id, (unsigned long long)peer);
/* Parse password from request (v2 format: type + pass_len + optional_password) */
char req_password[128] = "";
if (len >= 1) {
uint8_t plen2 = pl[0];
if (plen2 > 0 && plen2 <= len - 1) {
if (plen2 > 127) plen2 = 127;
memcpy(req_password, pl + 1, plen2); req_password[plen2] = '\0';
}
}
/* Check password if server has one configured */
{
struct global_config *gcfg = &cs->inst->config->global;
if (gcfg->chatserver_enabled && gcfg->chatserver_join_password[0]) {
if (strcmp(req_password, gcfg->chatserver_join_password) != 0) {
DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INFO_REQ wrong password ch=%s from=%016llx (got '%s', expected '%s')",
CS_ID, ch_id, (unsigned long long)peer, req_password, gcfg->chatserver_join_password);
uint8_t err[2] = { CS_MSG_ERROR, 0x01 }; /* 0x01 = wrong password */
cs_send(cs, ch_id, peer, err, 2);
return;
}
}
}
char name[128]; uint64_t owner; uint8_t x25519[32], ed_pub[32], ch_sig[64]; char name[128]; uint64_t owner; uint8_t x25519[32], ed_pub[32], ch_sig[64];
if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db,
ch_id, name, (int)sizeof(name), &owner, x25519, ed_pub, ch_sig) != 0) { ch_id, name, (int)sizeof(name), &owner, x25519, ed_pub, ch_sig) != 0) {
@ -1439,3 +1486,216 @@ static void cs_handle_peer_remove(struct chat_sync* cs, uint64_t peer,
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: PEER_REMOVE node=0x%016llx ch=%s", DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: PEER_REMOVE node=0x%016llx ch=%s",
CS_ID, (unsigned long long)node_id, ch_id); CS_ID, (unsigned long long)node_id, ch_id);
} }
/* ─── CHANNEL_INVITE (0x0F): inviter → joiner (push-приглашение в канал) ─── */
static void cs_handle_channel_invite(struct chat_sync* cs, uint64_t peer,
const char* ch_id, const uint8_t* pl, size_t len) {
DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE ch=%s from=%016llx len=%zu",
CS_ID, ch_id, (unsigned long long)peer, len);
if (len < 1 + 8 + 32 + 32 + 64 + 1 + 64 + 8 + 1) {
DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE too short len=%zu peer=%016llx", CS_ID, len, (unsigned long long)peer);
return;
}
/* Same payload format as CHANNEL_INFO_RESP */
const uint8_t* p = pl;
uint8_t nl = *p++;
if (1 + nl + 8 + 32 + 32 + 64 + 1 + 64 + 8 + 1 > (int)len) { DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE truncated", CS_ID); return; }
char name[128]; memcpy(name, p, nl); name[nl] = '\0'; p += nl;
uint64_t owner; memcpy(&owner, p, 8); p += 8;
const uint8_t* x25519 = p; p += 32;
const uint8_t* ed_pub = p; p += 32;
const uint8_t* ch_sig = p; p += 64;
uint8_t inv_flags = *p++;
const uint8_t* inviter_join_sig = NULL; uint64_t inviter_join_ts = 0;
if (inv_flags & PEERS_FLAG_HAS_JOIN) {
if (p + 72 > pl + len) return; inviter_join_sig = p; p += 64; memcpy(&inviter_join_ts, p, 8); p += 8; }
/* skip inviter_update_sig/ts/userinfo for now — verify channel sig only */
p += 64; uint64_t inviter_update_ts; memcpy(&inviter_update_ts, p, 8); p += 8;
uint8_t userinfo_len = *p++; p += userinfo_len;
/* Verify channel signature */
uint8_t vmsg[1024]; size_t vlen = 0;
vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", ch_id) + 1;
vlen += snprintf((char*)vmsg + vlen, sizeof(vmsg) - vlen, "%s", name) + 1;
memcpy(vmsg + vlen, &owner, 8); vlen += 8;
memcpy(vmsg + vlen, x25519, 32); vlen += 32;
memcpy(vmsg + vlen, ed_pub, 32); vlen += 32;
if (cs_ed25519_verify(ed_pub, vmsg, vlen, ch_sig) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE invalid ch_sig ch=%s from=%016llx", CS_ID, ch_id, (unsigned long long)peer);
return;
}
/* Check join_policy: server always autojoin, GUI uses chat_setting */
int policy;
{
struct global_config *gcfg = &cs->inst->config->global;
policy = gcfg->chatserver_enabled ? 0 : chat_setting_get_int("join_policy", 1);
}
DEBUG_DEBUG(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE policy=%d (chatserver=%d) ch=%s from=%016llx",
CS_ID, policy, cs->inst->config->global.chatserver_enabled, ch_id, (unsigned long long)peer);
if (policy == 2) { /* deny */
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE denied (policy=deny) ch=%s from=%016llx",
CS_ID, ch_id, (unsigned long long)peer);
return;
}
if (policy == 0) { /* autojoin */
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE autojoin ch=%s from=%016llx",
CS_ID, ch_id, (unsigned long long)peer);
/* Save channel and start join handshake */
topo_node_sqlite_channel_put(cs->inst->topo_sqlite_db,
ch_id, name, owner, x25519, NULL, ed_pub, NULL, ch_sig);
chat_core_ensure_channel_ready(ch_id);
/* Initiate join: connect back and send CHANNEL_INFO_REQ */
chat_sync_join_channel(cs->inst, ch_id, peer);
return;
}
/* policy == ask — post event to GUI */
{
size_t inv_name_len = 0; char inv_name[128] = "";
{ sqlite3* vdb = cs->inst->topo_sqlite_db;
sqlite3_stmt* st = NULL;
sqlite3_prepare_v2(vdb, "SELECT name FROM nodes WHERE node_id=?", -1, &st, NULL);
if (st) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)peer);
if (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* nm = sqlite3_column_text(st, 0);
if (nm) { snprintf(inv_name, sizeof(inv_name), "%s", nm); inv_name_len = strlen(inv_name); }
}
sqlite3_finalize(st);
}
}
uint8_t evt[512]; int epos = 0;
uint8_t cl = (uint8_t)strlen(ch_id); evt[epos++] = cl; memcpy(evt + epos, ch_id, cl); epos += cl;
evt[epos++] = nl; memcpy(evt + epos, name, nl); epos += nl;
memcpy(evt + epos, &peer, 8); epos += 8;
evt[epos++] = (uint8_t)inv_name_len;
if (inv_name_len) { memcpy(evt + epos, inv_name, inv_name_len); epos += inv_name_len; }
chat_event_post(CHAT_EVT_INVITE_RECEIVED, evt, epos);
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: CHANNEL_INVITE ask posted to GUI ch=%s name=%s from=%016llx (%s)",
CS_ID, ch_id, name, (unsigned long long)peer, inv_name);
}
}
/* ─── chat_sync_invite_to_channel: пригласить узел в наш канал ─── */
void chat_sync_invite_to_channel(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id) {
if (!g_cs || !inst || !ch_id || !ch_id[0]) return;
if (target_node_id == 0 || target_node_id == inst->node_id) return;
uint64_t channel_id = strtoull(ch_id, NULL, 10);
if (channel_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: invite_to_channel invalid ch_id='%s'", CS_ID, ch_id); return; }
/* Get channel data from DB */
char ch_name[128]; uint64_t owner; uint8_t ch_x25519[32], ch_ed[32], ch_sig[64];
if (topo_node_sqlite_channel_get(inst->topo_sqlite_db,
ch_id, ch_name, (int)sizeof(ch_name), &owner, ch_x25519, ch_ed, ch_sig) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: invite_to_channel channel not found: %s", CS_ID, ch_id);
return;
}
/* Check if already connected */
struct ETCP_CONN* conn = cs_find_conn_for_node(inst, target_node_id);
if (!conn || !conn->links_up || !conn->initialized) {
/* Try to connect via conn_mgr */
if (inst->topo_groups) {
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, channel_id);
if (g) {
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, target_node_id);
if (nq && nq->handle) {
/* Already have a handle — connection in progress or established */
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite_to_channel — existing handle for node 0x%016llx, waiting for conn_up",
CS_ID, (unsigned long long)target_node_id);
g_cs->pending_invite_ch_id = channel_id;
g_cs->pending_invite_node_id = target_node_id;
return;
}
}
/* Use conn_mgr_open_invite with channel group context */
struct TOPO_NODE* ni = u_calloc(1, sizeof(*ni));
if (ni) {
ni->node_id = target_node_id;
/* Addresses loaded from node_addresses in DB by conn_mgr */
int r = conn_mgr_open_invite(inst, channel_id, ni, target_node_id, NULL, NULL, NULL);
if (r < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: invite_to_channel conn_mgr_open_invite failed rc=%d", CS_ID, r);
}
u_free(ni);
}
} else {
DEBUG_WARN(DEBUG_CATEGORY_DB_SYNC, "%s: invite_to_channel — no topo_groups, cannot connect", CS_ID);
return;
}
/* Mark pending: after conn_up, we send CHANNEL_INVITE.
But we need to distinguish this from the joiner-side invite flow.
Use a separate flag. */
g_cs->pending_invite_ch_id = channel_id;
g_cs->pending_invite_node_id = target_node_id;
return;
}
/* Already connected — send invite now */
uint8_t join_sig[64] = {0}; uint64_t join_ts = 0;
{ sqlite3* vdb = inst->topo_sqlite_db;
topo_node_sqlite_member_get_join(vdb, ch_id, inst->node_id, join_sig, &join_ts); }
uint8_t upd_sig[64] = {0}; uint64_t upd_ts = (uint64_t)ntp_time_get_seconds(inst);
{
char juser[256]; snprintf(juser, sizeof(juser), "{\"name\":\"%s\"}", inst->name[0] ? inst->name : "");
uint8_t umsg[256]; size_t ulen = 0;
memcpy(umsg + ulen, join_sig, 64); ulen += 64;
memcpy(umsg + ulen, &upd_ts, 8); ulen += 8;
size_t jl = strlen(juser); memcpy(umsg + ulen, juser, jl); umsg[ulen + jl] = '\0'; ulen += jl + 1;
cs_ed25519_sign(inst->my_ed25519_privkey, umsg, ulen, upd_sig);
}
uint8_t buf[1024]; size_t boff = 0;
buf[boff++] = CS_MSG_CHANNEL_INVITE;
uint8_t cnl = (uint8_t)strlen(ch_name);
buf[boff++] = cnl; memcpy(buf + boff, ch_name, cnl); boff += cnl;
memcpy(buf + boff, &owner, 8); boff += 8;
memcpy(buf + boff, ch_x25519, 32); boff += 32;
memcpy(buf + boff, ch_ed, 32); boff += 32;
memcpy(buf + boff, ch_sig, 64); boff += 64;
uint8_t inv_flags = (join_sig[0] || join_ts) ? PEERS_FLAG_HAS_JOIN : 0;
buf[boff++] = inv_flags;
if (inv_flags & PEERS_FLAG_HAS_JOIN) {
memcpy(buf + boff, join_sig, 64); boff += 64;
memcpy(buf + boff, &join_ts, 8); boff += 8;
}
memcpy(buf + boff, upd_sig, 64); boff += 64;
memcpy(buf + boff, &upd_ts, 8); boff += 8;
char juser[256]; snprintf(juser, sizeof(juser), "{\"name\":\"%s\"}", inst->name[0] ? inst->name : "");
uint8_t il = (uint8_t)strlen(juser);
buf[boff++] = il; memcpy(buf + boff, juser, il); boff += il;
cs_send(g_cs, ch_id, target_node_id, buf, boff);
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: sent CHANNEL_INVITE to 0x%016llx ch=%s",
CS_ID, (unsigned long long)target_node_id, ch_id);
}
/* ─── accept/deny incoming invite ─── */
void chat_sync_accept_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t inviter_node_id) {
if (!g_cs || !inst) return;
char ch_id[64]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)channel_id);
if (channel_id == 0 || inviter_node_id == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DB_SYNC, "%s: accept_invite invalid params ch=%llu node=%016llx",
CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id);
return;
}
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: accept_invite ch=%llu inviter=0x%016llx",
CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id);
chat_core_ensure_channel_ready(ch_id);
chat_sync_join_channel(inst, ch_id, inviter_node_id);
}
void chat_sync_deny_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t inviter_node_id) {
(void)inst;
if (!g_cs) return;
DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: deny_invite ch=%llu inviter=0x%016llx",
CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id);
/* silently ignore — no response needed */
}

17
src/chat/chat_sync.h

@ -53,6 +53,7 @@ struct UASYNC;
#define CS_MSG_WELCOME 0x0C #define CS_MSG_WELCOME 0x0C
#define CS_MSG_PEER_UPSERT 0x0D #define CS_MSG_PEER_UPSERT 0x0D
#define CS_MSG_PEER_REMOVE 0x0E #define CS_MSG_PEER_REMOVE 0x0E
#define CS_MSG_CHANNEL_INVITE 0x0F /* push-invite: inviter → joiner */
/* Protocol constants */ /* Protocol constants */
#define CS_SEND_DATA_MAX 32 #define CS_SEND_DATA_MAX 32
@ -72,11 +73,23 @@ void chat_sync_destroy(struct UTUN_INSTANCE* inst);
/* Прямое ETCP-подключение к узлу (pubkey+адреса из БД) */ /* Прямое ETCP-подключение к узлу (pubkey+адреса из БД) */
void chat_sync_connect_node(struct UTUN_INSTANCE* inst, uint64_t node_id); void chat_sync_connect_node(struct UTUN_INSTANCE* inst, uint64_t node_id);
/* Подключиться к пиру по данным invite-ссылки (вызывается из uasync-потока) */ /* Подключиться к пиру по данным invite-ссылки (вызывается из uasync-потока).
password может быть NULL — тогда отправляется без пароля (совместимость с v1) */
void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id, void chat_sync_connect_from_invite(uint64_t channel_id, uint64_t node_id,
const uint8_t* pubkey_bin, const uint8_t* pubkey_bin,
const uint8_t* addrs_data, int addr_count, const uint8_t* addrs_data, int addr_count,
int addrs_data_len); int addrs_data_len,
const char* password);
/* Пригласить target_node_id в канал ch_id (inviter → joiner).
Отправляет CS_MSG_CHANNEL_INVITE с полной информацией о канале и своих адресах */
void chat_sync_invite_to_channel(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id);
/* Принять входящее приглашение — продолжаем join-протокол (CHANNEL_JOIN → WELCOME) */
void chat_sync_accept_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t inviter_node_id);
/* Отклонить входящее приглашение */
void chat_sync_deny_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t inviter_node_id);
/* Присоединиться к каналу через уже подключённый узел (uasync-поток). /* Присоединиться к каналу через уже подключённый узел (uasync-поток).
Соединение с target_node_id должно быть установлено (links_up && initialized). Соединение с target_node_id должно быть установлено (links_up && initialized).

170
src/config_parser.c

@ -52,6 +52,7 @@ typedef enum {
SECTION_GUI, SECTION_GUI,
SECTION_CHAT, SECTION_CHAT,
SECTION_NTP, SECTION_NTP,
SECTION_CHATSERVER,
SECTION_LOG_UDP SECTION_LOG_UDP
} section_type_t; } section_type_t;
@ -718,6 +719,51 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c
return -1; return -1;
} }
static int parse_size_with_suffix(const char *value, uint64_t *out_bytes) {
if (!value || !out_bytes) return -1;
char *end = NULL;
uint64_t num = strtoull(value, &end, 10);
if (end == value) return -1;
while (*end && isspace((unsigned char)*end)) end++;
if (*end == '\0') { *out_bytes = num; return 0; }
if (*end == 'M' || *end == 'm') { *out_bytes = num * 1024ULL * 1024ULL; return 0; }
if (*end == 'G' || *end == 'g') { *out_bytes = num * 1024ULL * 1024ULL * 1024ULL; return 0; }
return -1;
}
static int parse_chatserver(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) {
if (strcmp(key, "join_password") == 0) {
strncpy(global->chatserver_join_password, value, sizeof(global->chatserver_join_password) - 1);
global->chatserver_join_password[sizeof(global->chatserver_join_password) - 1] = '\0';
return 0;
}
if (strcmp(key, "allowed_groups") == 0) {
strncpy(global->chatserver_allowed_groups, value, sizeof(global->chatserver_allowed_groups) - 1);
global->chatserver_allowed_groups[sizeof(global->chatserver_allowed_groups) - 1] = '\0';
return 0;
}
if (strcmp(key, "storage_total_size") == 0) {
uint64_t sz;
if (parse_size_with_suffix(value, &sz) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid chatserver storage_total_size '%s' (expected number with M/G suffix)", filename, line_num, value);
return -1;
}
global->chatserver_storage_total_size = sz;
return 0;
}
if (strcmp(key, "storage_unit_size") == 0) {
uint64_t sz;
if (parse_size_with_suffix(value, &sz) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid chatserver storage_unit_size '%s' (expected number with M/G suffix)", filename, line_num, value);
return -1;
}
global->chatserver_storage_unit_size = sz;
return 0;
}
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: join_policy, join_password, allowed_groups, storage_total_size, storage_unit_size", filename, line_num, key);
return -1;
}
static int parse_network(const char *key, const char *value, struct CFG_NETWORK *net, const char *filename, int line_num) { static int parse_network(const char *key, const char *value, struct CFG_NETWORK *net, const char *filename, int line_num) {
if (strcmp(key, "id") == 0) { if (strcmp(key, "id") == 0) {
net->id = strtoull(value, NULL, 16); net->id = strtoull(value, NULL, 16);
@ -758,6 +804,7 @@ static section_type_t parse_section_header(const char *line, char *name, size_t
if (strcasecmp(section, "ntp") == 0) return SECTION_NTP; if (strcasecmp(section, "ntp") == 0) return SECTION_NTP;
if (strcasecmp(section, "gui") == 0) return SECTION_GUI; if (strcasecmp(section, "gui") == 0) return SECTION_GUI;
if (strcasecmp(section, "chat") == 0) return SECTION_CHAT; if (strcasecmp(section, "chat") == 0) return SECTION_CHAT;
if (strcasecmp(section, "chatserver") == 0) return SECTION_CHATSERVER;
if (strcasecmp(section, "log_udp") == 0) return SECTION_LOG_UDP; if (strcasecmp(section, "log_udp") == 0) return SECTION_LOG_UDP;
char *colon = strchr(section, ':'); char *colon = strchr(section, ':');
@ -802,6 +849,11 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
cfg->global.ntp_servers = NULL; cfg->global.ntp_servers = NULL;
cfg->global.ntp_server_count = 0; cfg->global.ntp_server_count = 0;
cfg->global.ntp_resync_interval = 3600; cfg->global.ntp_resync_interval = 3600;
cfg->global.chatserver_enabled = 0;
cfg->global.chatserver_join_password[0] = '\0';
cfg->global.chatserver_allowed_groups[0] = '\0';
cfg->global.chatserver_storage_total_size = 0;
cfg->global.chatserver_storage_unit_size = 0;
section_type_t cur_section = SECTION_UNKNOWN; section_type_t cur_section = SECTION_UNKNOWN;
struct CFG_SERVER *cur_server = NULL; struct CFG_SERVER *cur_server = NULL;
@ -993,6 +1045,12 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
/* chat_setting_set уже напечатал ошибку */ /* chat_setting_set уже напечатал ошибку */
} }
break; break;
case SECTION_CHATSERVER:
cfg->global.chatserver_enabled = 1;
if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0) {
/* parse_chatserver already printed the error */
}
break;
case SECTION_GUI: case SECTION_GUI:
break; break;
default: default:
@ -1076,6 +1134,11 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha
cfg->global.ntp_servers = NULL; cfg->global.ntp_servers = NULL;
cfg->global.ntp_server_count = 0; cfg->global.ntp_server_count = 0;
cfg->global.ntp_resync_interval = 3600; cfg->global.ntp_resync_interval = 3600;
cfg->global.chatserver_enabled = 0;
cfg->global.chatserver_join_password[0] = '\0';
cfg->global.chatserver_allowed_groups[0] = '\0';
cfg->global.chatserver_storage_total_size = 0;
cfg->global.chatserver_storage_unit_size = 0;
section_type_t cur_section = SECTION_UNKNOWN; section_type_t cur_section = SECTION_UNKNOWN;
struct CFG_SERVER *cur_server = NULL; struct CFG_SERVER *cur_server = NULL;
@ -1117,6 +1180,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha
case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break;
case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break;
case SECTION_CHAT: chat_setting_set(key, value); break; case SECTION_CHAT: chat_setting_set(key, value); break;
case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; parse_chatserver(key, value, &cfg->global, filename, line_num); break;
case SECTION_GUI: break; case SECTION_GUI: break;
case SECTION_LOG_UDP: case SECTION_LOG_UDP:
if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1); if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1);
@ -1323,6 +1387,112 @@ void print_config(const struct utun_config *cfg) {
} }
} }
static const char invite_b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
void print_chatserver_invite_link(const struct utun_config *config, const struct CFG_SERVER *servers) {
if (!config || !servers) return;
const struct global_config *g = &config->global;
if (!g->chatserver_enabled) return;
if (strlen(g->my_public_key_hex) < SC_PUBKEY_SIZE * 2) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "print_chatserver_invite_link: no public key");
return;
}
uint8_t pub_bin[SC_PUBKEY_SIZE];
/* Use first SC_PUBKEY_SIZE*2 hex chars (X25519 pubkey, may be part of longer combined key) */
{
char pub_hex_first[SC_PUBKEY_SIZE * 2 + 1];
memcpy(pub_hex_first, g->my_public_key_hex, SC_PUBKEY_SIZE * 2);
pub_hex_first[SC_PUBKEY_SIZE * 2] = '\0';
if (hex_to_binary(pub_hex_first, pub_bin, SC_PUBKEY_SIZE) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "print_chatserver_invite_link: invalid pubkey hex");
return;
}
}
/* Collect server addresses */
struct {
int family;
uint8_t addr[16];
uint16_t port;
uint8_t proto;
} addrs[32];
int addr_count = 0;
for (const struct CFG_SERVER *s = servers; s && addr_count < 32; s = s->next) {
if (s->ip.ss_family == AF_INET) {
struct sockaddr_in *sin = (struct sockaddr_in *)&s->ip;
addrs[addr_count].family = 4;
memcpy(addrs[addr_count].addr, &sin->sin_addr, 4);
addrs[addr_count].port = ntohs(sin->sin_port);
addrs[addr_count].proto = (s->transport == 1) ? 0x02 : 0x01;
addr_count++;
} else if (s->ip.ss_family == AF_INET6) {
struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&s->ip;
addrs[addr_count].family = 6;
memcpy(addrs[addr_count].addr, &sin6->sin6_addr, 16);
addrs[addr_count].port = ntohs(sin6->sin6_port);
addrs[addr_count].proto = (s->transport == 1) ? 0x02 : 0x01;
addr_count++;
}
}
if (addr_count == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "print_chatserver_invite_link: no server addresses");
return;
}
/* Build binary blob */
uint8_t raw[4096];
size_t pos = 0;
int has_pass = g->chatserver_join_password[0] ? 1 : 0;
raw[pos++] = has_pass ? 0x02 : 0x01; /* version */
if (has_pass) {
size_t plen = strlen(g->chatserver_join_password);
raw[pos++] = (uint8_t)plen;
memcpy(raw + pos, g->chatserver_join_password, plen); pos += plen;
}
/* channel_id = 0 (8 bytes BE) */
for (int i = 0; i < 8; i++) raw[pos++] = 0;
int ia = 0;
while (ia < addr_count) {
int rem = addr_count - ia;
int cnt = (rem > 4) ? 4 : rem;
uint8_t header = (uint8_t)((cnt - 1) & 0x03);
for (int j = 0; j < cnt; j++) if (addrs[ia + j].family == 6) header |= (uint8_t)(1 << (2 + j));
raw[pos++] = header;
memcpy(raw + pos, pub_bin, SC_PUBKEY_SIZE); pos += SC_PUBKEY_SIZE;
for (int j = 0; j < cnt; j++) {
int ip_len = (addrs[ia + j].family == 6) ? 16 : 4;
raw[pos++] = 0; /* socketId */
raw[pos++] = addrs[ia + j].proto; /* proto */
memcpy(raw + pos, addrs[ia + j].addr, (size_t)ip_len); pos += (size_t)ip_len;
raw[pos++] = (uint8_t)(addrs[ia + j].port >> 8);
raw[pos++] = (uint8_t)(addrs[ia + j].port & 0xFF);
}
ia += cnt;
}
/* Base64 encode */
char link[2048];
memcpy(link, "utun://", 7);
size_t opos = 7;
size_t i = 0;
while (i < pos) {
uint32_t val = (uint32_t)raw[i] << 16;
val |= (i + 1 < pos) ? (uint32_t)raw[i + 1] << 8 : 0;
val |= (i + 2 < pos) ? (uint32_t)raw[i + 2] : 0;
link[opos++] = invite_b64[(val >> 18) & 0x3F];
link[opos++] = invite_b64[(val >> 12) & 0x3F];
link[opos++] = (i + 1 < pos) ? invite_b64[(val >> 6) & 0x3F] : '=';
link[opos++] = (i + 2 < pos) ? invite_b64[val & 0x3F] : '=';
i += 3;
}
link[opos] = '\0';
DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Chatserver invite link: %s", link);
fprintf(stderr, "Chatserver invite link: %s\n", link);
}
int update_config_keys(const char *filename, const char *priv_key, const char *pub_key) { int update_config_keys(const char *filename, const char *priv_key, const char *pub_key) {
// Minimal implementation: just append to file // Minimal implementation: just append to file
FILE *fp = fopen(filename, "a"); FILE *fp = fopen(filename, "a");

8
src/config_parser.h

@ -194,6 +194,13 @@ struct global_config {
struct CFG_NTP_SERVER *ntp_servers; // linked list, unlimited struct CFG_NTP_SERVER *ntp_servers; // linked list, unlimited
int ntp_server_count; int ntp_server_count;
int ntp_resync_interval; // seconds, default 3600 int ntp_resync_interval; // seconds, default 3600
// Chatserver configuration ([chatserver] section)
int chatserver_enabled; // 1 = section present
char chatserver_join_password[128]; // пароль для входа в группу
char chatserver_allowed_groups[1024]; // "all" или hex-ID через запятую
uint64_t chatserver_storage_total_size; // bytes, общий лимит хранилища (0 = безлимит)
uint64_t chatserver_storage_unit_size; // bytes, лимит одного файла (0 = отключено)
}; };
struct utun_config { struct utun_config {
@ -209,6 +216,7 @@ struct utun_config* parse_config(const char *filename);
struct utun_config* parse_config_from_buf(const char *buf, size_t len, const char *filename); struct utun_config* parse_config_from_buf(const char *buf, size_t len, const char *filename);
void free_config(struct utun_config *config); void free_config(struct utun_config *config);
void print_config(const struct utun_config *config); void print_config(const struct utun_config *config);
void print_chatserver_invite_link(const struct utun_config *config, const struct CFG_SERVER *servers);
int update_config_keys(const char *filename, const char *priv_key, const char *pub_key); int update_config_keys(const char *filename, const char *priv_key, const char *pub_key);
#ifdef __cplusplus #ifdef __cplusplus

1
src/utun.c

@ -348,6 +348,7 @@ int main(int argc, char *argv[]) {
// Print config for debugging // Print config for debugging
if (args.foreground) { if (args.foreground) {
print_config(instance->config); print_config(instance->config);
print_chatserver_invite_link(instance->config, instance->config->servers);
} }
// Initialize all components (TUN, routing, connections) // Initialize all components (TUN, routing, connections)

1
tools/chatgui-android/app/build.gradle.kts

@ -69,6 +69,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("com.google.code.gson:gson:2.10.1") implementation("com.google.code.gson:gson:2.10.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0") implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("com.google.zxing:core:3.5.3")
implementation("androidx.camera:camera-camera2:1.4.0") implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0") implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0") implementation("androidx.camera:camera-view:1.4.0")

16
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.utun.chat.data.InviteData import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.* import com.utun.chat.ui.screens.*
@ -30,6 +31,7 @@ class MainActivity : ComponentActivity() {
var qrResult by remember { mutableStateOf<String?>(null) } var qrResult by remember { mutableStateOf<String?>(null) }
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) } var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteLinkText by remember { mutableStateOf<String?>(null) }
BackHandler(enabled = screen != Screen.ChannelList) { BackHandler(enabled = screen != Screen.ChannelList) {
when (screen) { when (screen) {
@ -57,6 +59,16 @@ class MainActivity : ComponentActivity() {
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onCreateChannel = { vm.createChannel("general", "channel_1") }, onCreateChannel = { vm.createChannel("general", "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = { ch ->
val chId = ch.id.toLongOrNull()
if (chId == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
} else {
val link = NativeLib.encodeInviteLink(chId)
if (link != null) inviteLinkText = link
else LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId")
}
},
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs }, onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() } onRefresh = { vm.requestRefresh() }
@ -128,6 +140,10 @@ class MainActivity : ComponentActivity() {
onDismiss = { showJoinDialog = false } onDismiss = { showJoinDialog = false }
) )
} }
inviteLinkText?.let { link ->
InviteLinkDialog(link = link, onDismiss = { inviteLinkText = null })
}
} }
} }
} }

3
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -48,6 +48,8 @@ object NativeLib {
fun getMyNodeId(): Long = nativeGetMyNodeId() fun getMyNodeId(): Long = nativeGetMyNodeId()
fun encodeInviteLink(channelId: Long): String? = nativeEncodeInviteLink(channelId)
fun ping() { nativePing() } fun ping() { nativePing() }
fun isResponsive(): Boolean = nativeIsResponsive() fun isResponsive(): Boolean = nativeIsResponsive()
@ -130,6 +132,7 @@ object NativeLib {
private external fun nativeRegenerateKeys() private external fun nativeRegenerateKeys()
private external fun nativeClearDatabase(): Boolean private external fun nativeClearDatabase(): Boolean
private external fun nativeGetMyNodeId(): Long private external fun nativeGetMyNodeId(): Long
private external fun nativeEncodeInviteLink(channelId: Long): String?
private external fun nativeRestart(configText: String): Boolean private external fun nativeRestart(configText: String): Boolean
private external fun nativePing() private external fun nativePing()
private external fun nativeIsResponsive(): Boolean private external fun nativeIsResponsive(): Boolean

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt

@ -33,6 +33,7 @@ fun ChannelListScreen(
onSelectChannel: (Channel) -> Unit, onSelectChannel: (Channel) -> Unit,
onCreateChannel: () -> Unit, onCreateChannel: () -> Unit,
onJoinChannel: () -> Unit, onJoinChannel: () -> Unit,
onInviteClick: (Channel) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onLogs: () -> Unit, onLogs: () -> Unit,
onRefresh: () -> Unit = {} onRefresh: () -> Unit = {}
@ -87,7 +88,8 @@ fun ChannelListScreen(
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) { Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small) { Surface(shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onInviteClick(ch) }) {
Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp)) Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp))
} }
if (badgeText.isNotEmpty()) { if (badgeText.isNotEmpty()) {

2
tools/chatgui-android/headless/headless_control.c

@ -315,7 +315,7 @@ static int handle_join(int fd, int id, const char* json) {
} }
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "join: calling chat_sync_connect_from_invite..."); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "join: calling chat_sync_connect_from_invite...");
chat_sync_connect_from_invite(d.channelId, d.nodeId, d.pubkey, addrs_buf, d.addrCount, addrs_len); chat_sync_connect_from_invite(d.channelId, d.nodeId, d.pubkey, addrs_buf, d.addrCount, addrs_len, d.password);
char resp[512]; char resp[512];
snprintf(resp, sizeof(resp), snprintf(resp, sizeof(resp),

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

@ -211,6 +211,7 @@ void utun_bridge_connect_node(const char* address, int port, const char* pubkey_
/* Real implementation: calls chat_sync_connect_from_invite() */ /* Real implementation: calls chat_sync_connect_from_invite() */
#include "chat/chat_sync.h" #include "chat/chat_sync.h"
#include "chat/chat_core.h" #include "chat/chat_core.h"
#include "chat/chat_core_priv.h"
void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id, void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id,
const uint8_t* pubkey_bin, const uint8_t* pubkey_bin,
@ -227,7 +228,206 @@ void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id,
utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */ utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */
return; return;
} }
chat_sync_connect_from_invite(channel_id, real_node_id, pubkey_bin, addrs_data, addr_count, addrs_data_len); 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) {
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 TCP_SOCKET* ts = g_cc.inst->tcp_sockets;
while (ts) {
struct sockaddr_storage* sa = ts->interface_addr.ss_family ? &ts->interface_addr : &ts->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 = ts->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 = ts->sock_id; out[cnt].proto = INVITE_PROTO_TCP;
memcpy(out[cnt].address, &sin6->sin6_addr, 16);
out[cnt].port = ntohs(sin6->sin6_port); cnt++;
}
ts = ts->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 public_key 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);
return NULL;
}
return u_strdup(out);
} }
void utun_bridge_connect_channel(const char* channel_id) { void utun_bridge_connect_channel(const char* channel_id) {
@ -243,8 +443,6 @@ void utun_bridge_connect_channel(const char* channel_id) {
uasync_post(ua, chat_core_connect_channel_trampoline, cid); uasync_post(ua, chat_core_connect_channel_trampoline, cid);
} }
#include "chat/chat_core_priv.h"
static char* json_escape_alloc(const char* str) { static char* json_escape_alloc(const char* str) {
if (!str) return u_strdup(""); if (!str) return u_strdup("");
size_t len = strlen(str), cap = len * 2 + 8; size_t len = strlen(str), cap = len * 2 + 8;
@ -1200,6 +1398,18 @@ JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeDeriveNodeId(
return (jlong)node_id; return (jlong)node_id;
} }
/* ── nativeEncodeInviteLink ── */
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeEncodeInviteLink(
JNIEnv* env, jobject thiz, jlong channelId) {
(void)thiz;
char* link = utun_bridge_encode_invite_link((uint64_t)channelId, NULL);
if (!link) return NULL;
jstring result = (*env)->NewStringUTF(env, link);
u_free(link);
return result;
}
/* ── nativeSetEventCallback(eventCb) ── */ /* ── nativeSetEventCallback(eventCb) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback( JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback(

3
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -103,6 +103,9 @@ void* utun_bridge_voice_decode_open(const char* file_path,
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples); int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
void utun_bridge_voice_decode_close(void* handle); void utun_bridge_voice_decode_close(void* handle);
/* ── Invite link encode (for sharing) ── */
char* utun_bridge_encode_invite_link(uint64_t channel_id, const char* password);
/* ── Debug ── */ /* ── Debug ── */
void utun_bridge_set_debug_level(const char* category, const char* level); void utun_bridge_set_debug_level(const char* category, const char* level);

36
tools/chatgui-android/libutun_lite/invite_link_c.c

@ -71,10 +71,31 @@ int invite_link_decode(const char* link, size_t link_len, struct InviteDataC* ou
} }
int off = 0; int off = 0;
if (raw[off++] != INVITE_LINK_VERSION) { uint8_t ver = raw[off++];
if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "unsupported version 0x%02x", raw[off - 1]); if (ver != INVITE_LINK_VERSION && ver != 0x02) {
if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "unsupported version 0x%02x", ver);
return -1; return -1;
} }
out->password[0] = '\0'; out->password_len = 0;
/* v2: password field before addresses */
if (ver == 0x02) {
if (off + 1 > raw_len) {
if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at pass_len");
return -1;
}
uint8_t plen = raw[off++];
if (plen > 0) {
if (plen > INVITE_PASS_MAX - 1) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "password too long %d", plen); return -1; }
if (off + plen > raw_len) {
if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at password");
return -1;
}
memcpy(out->password, raw + off, plen); out->password[plen] = '\0'; out->password_len = plen;
off += plen;
}
}
if (off + 8 > raw_len) { if (off + 8 > raw_len) {
if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at channel_id"); if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at channel_id");
return -1; return -1;
@ -145,13 +166,20 @@ int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t
return (int)pos; return (int)pos;
} }
int invite_link_encode(const struct InviteDataC* data, char* out, size_t out_size) { int invite_link_encode(const struct InviteDataC* data, const char* password, char* out, size_t out_size) {
if (!data || !out || data->pubkey[0] == 0) return -1; if (!data || !out || data->pubkey[0] == 0) return -1;
if (data->addrCount == 0) return -1; if (data->addrCount == 0) return -1;
int has_pass = (password && password[0]) ? 1 : 0;
uint8_t raw[4096]; uint8_t raw[4096];
size_t pos = 0; size_t pos = 0;
raw[pos++] = INVITE_LINK_VERSION; raw[pos++] = has_pass ? 0x02 : INVITE_LINK_VERSION;
if (has_pass) {
size_t plen = strlen(password);
if (plen > INVITE_PASS_MAX - 1) return -1;
raw[pos++] = (uint8_t)plen;
memcpy(raw + pos, password, plen); pos += plen;
}
for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)((data->channelId >> (i * 8)) & 0xFF); for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)((data->channelId >> (i * 8)) & 0xFF);
uint8_t pk[32]; memcpy(pk, data->pubkey, 32); uint8_t pk[32]; memcpy(pk, data->pubkey, 32);

8
tools/chatgui-android/libutun_lite/invite_link_c.h

@ -7,6 +7,7 @@
#define INVITE_LINK_VERSION 0x01 #define INVITE_LINK_VERSION 0x01
#define INVITE_PUBKEY_SIZE 32 #define INVITE_PUBKEY_SIZE 32
#define INVITE_ADDR_MAX 32 #define INVITE_ADDR_MAX 32
#define INVITE_PASS_MAX 128
#define INVITE_PROTO_UDP 0x01 #define INVITE_PROTO_UDP 0x01
#define INVITE_PROTO_TCP 0x02 #define INVITE_PROTO_TCP 0x02
@ -30,6 +31,8 @@ struct InviteDataC {
uint64_t nodeId; uint64_t nodeId;
uint8_t addrCount; uint8_t addrCount;
struct InviteAddrC addrs[INVITE_ADDR_MAX]; struct InviteAddrC addrs[INVITE_ADDR_MAX];
char password[INVITE_PASS_MAX]; /* empty = no password (v1 compat) */
uint8_t password_len;
}; };
/* decode utun://base64 link string, returns 0 on success, -1 on error. /* decode utun://base64 link string, returns 0 on success, -1 on error.
@ -38,9 +41,10 @@ int invite_link_decode(const char* link, size_t link_len, struct InviteDataC* ou
char* error_buf, size_t error_buf_size); char* error_buf, size_t error_buf_size);
/* encode invite data to utun://base64 string. /* encode invite data to utun://base64 string.
out buffer must be large enough (depends on addr count, ~512 bytes safe). password may be NULL (v1 format, no password).
out buffer must be large enough (~512 bytes safe).
returns written length (excluding null), or -1 on error */ returns written length (excluding null), or -1 on error */
int invite_link_encode(const struct InviteDataC* data, char* out, size_t out_size); int invite_link_encode(const struct InviteDataC* data, const char* password, char* out, size_t out_size);
/* serialize per-addr format for chat_sync_connect_from_invite(): /* serialize per-addr format for chat_sync_connect_from_invite():
family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE)

38
tools/chatgui/src/invite_link.cpp

@ -3,7 +3,8 @@
#include <QByteArray> #include <QByteArray>
QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey,
const QList<NodeAddr>& addrs) { const QList<NodeAddr>& addrs,
const QString& password) {
if (pubkey.size() != 32) { if (pubkey.size() != 32) {
GUI_ERROR("encodeInviteLink: pubkey must be 32 bytes (got %d)", pubkey.size()); GUI_ERROR("encodeInviteLink: pubkey must be 32 bytes (got %d)", pubkey.size());
return {}; return {};
@ -14,9 +15,16 @@ QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey,
} }
QByteArray blob; QByteArray blob;
blob.reserve(1 + 8 + addrs.size() * (1 + 32 + 20)); int hasPass = !password.isEmpty() ? 1 : 0;
blob.reserve(1 + (hasPass ? 1 + password.size() : 0) + 8 + addrs.size() * (1 + 32 + 20));
blob.append(static_cast<char>(INVITE_LINK_VERSION)); blob.append(static_cast<char>(hasPass ? 0x02 : INVITE_LINK_VERSION));
if (hasPass) {
QByteArray passBytes = password.toUtf8();
blob.append(static_cast<char>(passBytes.size()));
blob.append(passBytes);
}
/* channel_id: uint64 big-endian */ /* channel_id: uint64 big-endian */
for (int i = 7; i >= 0; i--) for (int i = 7; i >= 0; i--)
@ -73,11 +81,29 @@ InviteData decodeInviteLink(const QString& link) {
} }
int off = 0; int off = 0;
if (data[off++] != INVITE_LINK_VERSION) { uint8_t ver = data[off++];
d.error = QStringLiteral("unsupported version 0x%1").arg(data[off - 1], 2, 16, QLatin1Char('0')); if (ver != INVITE_LINK_VERSION && ver != 0x02) {
GUI_ERROR("decodeInviteLink: unsupported version %d", data[off - 1]); d.error = QStringLiteral("unsupported version 0x%1").arg(ver, 2, 16, QLatin1Char('0'));
GUI_ERROR("decodeInviteLink: unsupported version %d", ver);
return d;
}
/* v2: password field before channel_id */
if (ver == 0x02) {
if (off >= len) {
d.error = QStringLiteral("truncated at pass_len");
return d; return d;
} }
uint8_t plen = data[off++];
if (plen > 0) {
if (off + plen > len) {
d.error = QStringLiteral("truncated at password");
return d;
}
d.password = QString::fromUtf8(reinterpret_cast<const char*>(data + off), plen);
off += plen;
}
}
/* channel_id: uint64 big-endian */ /* channel_id: uint64 big-endian */
if (off + 8 > len) { if (off + 8 > len) {

4
tools/chatgui/src/invite_link.h

@ -24,10 +24,12 @@ struct InviteData {
uint64_t channelId = 0; uint64_t channelId = 0;
QByteArray pubkey; QByteArray pubkey;
QList<InviteAddr> addrs; QList<InviteAddr> addrs;
QString password;
QString error; QString error;
}; };
QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey,
const QList<NodeAddr>& addrs); const QList<NodeAddr>& addrs,
const QString& password = QString());
InviteData decodeInviteLink(const QString& link); InviteData decodeInviteLink(const QString& link);

123
tools/chatgui/src/invitedialog.cpp

@ -1,5 +1,14 @@
#include "invitedialog.h" #include "invitedialog.h"
#include "invite_link.h"
#include "qrcode_utils.h" #include "qrcode_utils.h"
#include "debug_ui.h"
#include "../transport/utun_node.h"
#include "../transport/gui_bridge.h"
#include "../db/db_manager.h"
extern "C" {
#include "chat/chat_sync.h"
#include "transport_layer/secure_channel.h"
}
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLineEdit> #include <QLineEdit>
@ -8,9 +17,11 @@
#include <QClipboard> #include <QClipboard>
#include <QApplication> #include <QApplication>
#include <QTimer> #include <QTimer>
#include <QFrame>
InviteDialog::InviteDialog(const QString& link, QWidget* parent) InviteDialog::InviteDialog(const QString& link, const QString& channelId,
: QDialog(parent), m_link(link) UtunNode* node, QWidget* parent)
: QDialog(parent), m_link(link), m_channelId(channelId), m_node(node)
{ {
setWindowTitle("Пригласительная ссылка"); setWindowTitle("Пригласительная ссылка");
setMinimumWidth(420); setMinimumWidth(420);
@ -25,7 +36,7 @@ InviteDialog::InviteDialog(const QString& link, QWidget* parent)
layout->addWidget(linkEdit); layout->addWidget(linkEdit);
auto *btnLayout = new QHBoxLayout(); auto *btnLayout = new QHBoxLayout();
auto *copyBtn = new QPushButton("Скопировать", this); auto *copyBtn = new QPushButton("Копировать", this);
connect(copyBtn, &QPushButton::clicked, this, [this, linkEdit]() { connect(copyBtn, &QPushButton::clicked, this, [this, linkEdit]() {
QClipboard* cb = QApplication::clipboard(); QClipboard* cb = QApplication::clipboard();
cb->clear(QClipboard::Clipboard); cb->clear(QClipboard::Clipboard);
@ -48,6 +59,44 @@ InviteDialog::InviteDialog(const QString& link, QWidget* parent)
qrLabel->setAlignment(Qt::AlignCenter); qrLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(qrLabel, 0, Qt::AlignCenter); layout->addWidget(qrLabel, 0, Qt::AlignCenter);
/* ── Разделитель ── */
auto *sep = new QFrame(this);
sep->setFrameShape(QFrame::HLine);
sep->setStyleSheet("QFrame { color: palette(mid); }");
layout->addWidget(sep);
/* ── Пригласить по чужой ссылке ── */
auto *inviteTitle = new QLabel("Пригласить в эту группу:", this);
inviteTitle->setStyleSheet("font-weight: bold; font-size: 12px;");
layout->addWidget(inviteTitle);
m_inviteLinkEdit = new QLineEdit(this);
m_inviteLinkEdit->setPlaceholderText("utun://...");
m_inviteLinkEdit->setStyleSheet(
"QLineEdit { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 8px; font-size: 12px; background: palette(base); }");
layout->addWidget(m_inviteLinkEdit);
m_inviteStatusLabel = new QLabel(this);
m_inviteStatusLabel->setWordWrap(true);
m_inviteStatusLabel->setStyleSheet("color: gray; font-size: 11px; padding: 2px 0;");
layout->addWidget(m_inviteStatusLabel);
auto *invBtnLayout = new QHBoxLayout();
invBtnLayout->addStretch();
m_inviteSendBtn = new QPushButton("Пригласить", this);
m_inviteSendBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px;"
" padding: 5px 14px; font-size: 12px; font-weight: bold;"
" background: #3390EC; color: white; }"
"QPushButton:hover { background: #2B7ED4; }"
"QPushButton:disabled { background: palette(mid); color: palette(midlight); }");
connect(m_inviteSendBtn, &QPushButton::clicked, this, &InviteDialog::onSendInvite);
invBtnLayout->addWidget(m_inviteSendBtn);
layout->addLayout(invBtnLayout);
connect(m_inviteLinkEdit, &QLineEdit::returnPressed, this, &InviteDialog::onSendInvite);
auto *closeBtn = new QPushButton("OK", this); auto *closeBtn = new QPushButton("OK", this);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept); connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept);
layout->addWidget(closeBtn, 0, Qt::AlignRight); layout->addWidget(closeBtn, 0, Qt::AlignRight);
@ -62,3 +111,71 @@ InviteDialog::InviteDialog(const QString& link, QWidget* parent)
} }
}); });
} }
void InviteDialog::onInviteByLink() {
/* Toggle visibility of invite-by-link section (handled in constructor now) */
}
void InviteDialog::onInviteLinkTextChanged() {
}
void InviteDialog::setInviteStatus(const QString& text, bool isError) {
if (isError)
m_inviteStatusLabel->setStyleSheet("color: #D0021B; font-size: 11px; padding: 2px 0;");
else
m_inviteStatusLabel->setStyleSheet("color: gray; font-size: 11px; padding: 2px 0;");
m_inviteStatusLabel->setText(text);
}
void InviteDialog::onSendInvite() {
if (m_inviteSending) return;
QString link = m_inviteLinkEdit->text().trimmed();
if (link.isEmpty()) {
setInviteStatus("Вставьте utun:// ссылку узла.", true);
return;
}
InviteData d = decodeInviteLink(link);
if (!d.error.isEmpty()) {
setInviteStatus("Неверная ссылка: " + d.error, true);
return;
}
if (d.pubkey.size() != 32) {
setInviteStatus("Pubkey не 32 байта.", true);
return;
}
if (d.addrs.isEmpty()) {
setInviteStatus("Нет адресов.", true);
return;
}
uint64_t targetNodeId = sc_derive_node_id_from_pubkey((const uint8_t*)d.pubkey.constData());
m_inviteSending = true;
m_inviteTargetNodeId = targetNodeId;
m_inviteSendBtn->setEnabled(false);
setInviteStatus("Приглашаем узел 0x" + QString::number(targetNodeId, 16) + "...");
/* Save addresses to DB */
QByteArray addrsBuf;
for (const auto& a : d.addrs) {
addrsBuf.append(static_cast<char>(a.family));
addrsBuf.append(static_cast<char>(a.socketId));
addrsBuf.append(static_cast<char>(a.proto));
addrsBuf.append(a.address);
addrsBuf.append(static_cast<char>((a.port >> 8) & 0xFF));
addrsBuf.append(static_cast<char>(a.port & 0xFF));
}
GUI_INFO("invite_by_link: channel=%s target=0x%016llx addrs=%d",
m_channelId.toUtf8().constData(), targetNodeId, d.addrs.size());
/* Post to uasync thread */
chat_sync_invite_to_channel(
chat_core_get_inst(), m_channelId.toUtf8().constData(), targetNodeId);
setInviteStatus("Приглашение отправлено. OK");
QTimer::singleShot(2000, this, [this]() {
m_inviteSending = false;
m_inviteSendBtn->setEnabled(true);
});
}

23
tools/chatgui/src/invitedialog.h

@ -2,12 +2,33 @@
#include <QDialog> #include <QDialog>
#include <QString> #include <QString>
#include <QLineEdit>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
class UtunNode;
class InviteDialog : public QDialog { class InviteDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
explicit InviteDialog(const QString& link, QWidget* parent = nullptr); explicit InviteDialog(const QString& link, const QString& channelId,
UtunNode* node, QWidget* parent = nullptr);
private slots:
void onInviteByLink();
void onInviteLinkTextChanged();
void onSendInvite();
private: private:
void setInviteStatus(const QString& text, bool isError = false);
QString m_link; QString m_link;
QString m_channelId;
UtunNode* m_node;
QLineEdit* m_inviteLinkEdit = nullptr;
QLabel* m_inviteStatusLabel = nullptr;
QPushButton* m_inviteSendBtn = nullptr;
bool m_inviteSending = false;
uint64_t m_inviteTargetNodeId = 0;
}; };

3
tools/chatgui/src/joindialog.cpp

@ -185,10 +185,11 @@ void JoinDialog::onConnectClicked() {
} }
int addrCount = d.addrs.size(); int addrCount = d.addrs.size();
const char* pass = d.password.isEmpty() ? NULL : d.password.toUtf8().constData();
chat_sync_connect_from_invite(d.channelId, nodeId, chat_sync_connect_from_invite(d.channelId, nodeId,
(const uint8_t*)d.pubkey.constData(), (const uint8_t*)d.pubkey.constData(),
(const uint8_t*)addrsBuf.constData(), addrCount, addrsBuf.size()); (const uint8_t*)addrsBuf.constData(), addrCount, addrsBuf.size(), pass);
} }
void JoinDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result) { void JoinDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result) {

2
tools/chatgui/src/mainwindow.cpp

@ -364,7 +364,7 @@ void MainWindow::showInviteDialog(const QString& channelId) {
return; return;
} }
InviteDialog dlg(link, this); InviteDialog dlg(link, channelId, m_node, this);
dlg.exec(); dlg.exec();
} }

29
tools/chatgui/src/settingsdialog.cpp

@ -84,6 +84,16 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;" "QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }"); " padding: 6px 10px; font-size: 13px; background: palette(base); }");
profLayout->addRow(sortLabel, m_sortCombo); profLayout->addRow(sortLabel, m_sortCombo);
auto* joinLabel = new QLabel("Join requests:", profilePage);
m_joinPolicyCombo = new QComboBox(profilePage);
m_joinPolicyCombo->addItem("Auto-join");
m_joinPolicyCombo->addItem("Ask before joining");
m_joinPolicyCombo->addItem("Deny all");
m_joinPolicyCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
profLayout->addRow(joinLabel, m_joinPolicyCombo);
m_pages->addWidget(profilePage); m_pages->addWidget(profilePage);
// Storage page // Storage page
@ -151,6 +161,8 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
if (db) { if (db) {
int sortMode = db->getUiStateInt("channel_sort", 0); int sortMode = db->getUiStateInt("channel_sort", 0);
m_sortCombo->setCurrentIndex(sortMode == 1 ? 1 : 0); m_sortCombo->setCurrentIndex(sortMode == 1 ? 1 : 0);
int joinPolicy = db->getUiStateInt("join_policy", 1);
m_joinPolicyCombo->setCurrentIndex(joinPolicy >= 0 && joinPolicy <= 2 ? joinPolicy : 1);
} }
m_networkPage->loadFromConfig(m_configPath); m_networkPage->loadFromConfig(m_configPath);
m_soundPage->loadFromDb(); m_soundPage->loadFromDb();
@ -237,5 +249,22 @@ void SettingsDialog::onSave() {
m_soundPage->applyAndSave(); m_soundPage->applyAndSave();
m_audioDevicePage->applyAndSave(); m_audioDevicePage->applyAndSave();
m_storagePage->applyAndSave(); m_storagePage->applyAndSave();
/* save join_policy */
{
const char* key = "join_policy";
int v = m_joinPolicyCombo->currentIndex();
QByteArray val = QByteArray::number(v);
size_t klen = strlen(key), vl = (size_t)val.size(), total = klen + 1 + vl + 1;
if (total <= 256) {
void* arg = u_malloc(total);
memcpy(arg, key, klen + 1);
memcpy((char*)arg + klen + 1, val.constData(), vl + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
}
if (!m_configPath.isEmpty()) {
postChatSetting(m_configPath, "join_policy", v);
}
}
accept(); accept();
} }

1
tools/chatgui/src/settingsdialog.h

@ -39,6 +39,7 @@ private:
QPushButton* m_saveBtn; QPushButton* m_saveBtn;
QLineEdit* m_nicknameEdit; QLineEdit* m_nicknameEdit;
QComboBox* m_sortCombo; QComboBox* m_sortCombo;
QComboBox* m_joinPolicyCombo;
QString m_oldNickname; QString m_oldNickname;
NetworkSettingsPage* m_networkPage; NetworkSettingsPage* m_networkPage;
DatabaseSettingsPage* m_databasePage; DatabaseSettingsPage* m_databasePage;

5
tools/chatgui/transport/gui_bridge.h

@ -95,6 +95,11 @@ void gui_bridge_set_download_progress_cb(gui_download_progress_fn cb);
typedef void (*gui_nodeinfo_update_fn)(const uint8_t* data, int len); typedef void (*gui_nodeinfo_update_fn)(const uint8_t* data, int len);
void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn cb); void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn cb);
/* Callback для входящих приглашений (вызывается из GUI-потока)
data: [ch_id_len:1][ch_id][name_len:1][name][inviter_node_id:8][inviter_name_len:1][inviter_name] */
typedef void (*gui_invite_received_fn)(const uint8_t* data, int len);
void gui_bridge_set_invite_received_cb(gui_invite_received_fn cb);
/* uTun nodeinfo callback — registered via utun_add_nodeinfo_cbk, called from uasync thread */ /* 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); void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg);

8
tools/chatgui/transport/gui_bridge_impl.cpp

@ -38,6 +38,7 @@ static gui_status_refresh_fn g_status_refresh_cb = nullptr;
static gui_attachment_downloaded_fn g_attachment_downloaded_cb = nullptr; static gui_attachment_downloaded_fn g_attachment_downloaded_cb = nullptr;
static gui_download_progress_fn g_download_progress_cb = nullptr; static gui_download_progress_fn g_download_progress_cb = nullptr;
static gui_nodeinfo_update_fn g_nodeinfo_update_cb = nullptr; static gui_nodeinfo_update_fn g_nodeinfo_update_cb = nullptr;
static gui_invite_received_fn g_invite_received_cb = nullptr;
static struct UASYNC* g_ua = nullptr; static struct UASYNC* g_ua = nullptr;
/* ── GuiBridgeReceiver implementation ── */ /* ── GuiBridgeReceiver implementation ── */
@ -170,6 +171,9 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
case GUI_EVT_NODEINFO_UPDATE: case GUI_EVT_NODEINFO_UPDATE:
if (g_nodeinfo_update_cb) g_nodeinfo_update_cb(d, dlen); if (g_nodeinfo_update_cb) g_nodeinfo_update_cb(d, dlen);
break; break;
case 18: /* CHAT_EVT_INVITE_RECEIVED */
if (g_invite_received_cb) g_invite_received_cb(d, dlen);
break;
default: default:
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType); DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType);
break; break;
@ -264,6 +268,10 @@ void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn cb) {
g_nodeinfo_update_cb = cb; g_nodeinfo_update_cb = cb;
} }
void gui_bridge_set_invite_received_cb(gui_invite_received_fn cb) {
g_invite_received_cb = cb;
}
} /* extern "C" */ } /* extern "C" */
/* ── uTun nodeinfo callback (called from uasync thread) ── */ /* ── uTun nodeinfo callback (called from uasync thread) ── */

7
utun.conf.sample

@ -107,3 +107,10 @@ allow=all
# Принимает CONNECT-запросы от других нод и открывает OS сокеты к адресатам. # Принимает CONNECT-запросы от других нод и открывает OS сокеты к адресатам.
#[tcp_proxy_server] #[tcp_proxy_server]
#enabled=yes #enabled=yes
# --- Chatserver: headless supernode config ---
#[chatserver]
#join_password=secret123 # пароль входа в группы (будет в invite-ссылке)
#allowed_groups=all # "all" или hex-ID через запятую
#storage_total_size=500M # общий лимит медиакеша (0=безлимит)
#storage_unit_size=50M # лимит одного медиафайла (0=отключено)

Loading…
Cancel
Save