diff --git a/111.txt b/111.txt deleted file mode 100644 index fbe9b168..00000000 --- a/111.txt +++ /dev/null @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 86045123..238e5be3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,1084 +1,53 @@ -# uTun — Архитектура системы - -Документ для разработчиков. Описывает внутреннее устройство, дизайн-решения, взаимодействие модулей и поток данных. - ---- - -## 1. Обзор и дизайн-решения - -uTun — защищённый VPN-туннель с собственным надёжным транспортным протоколом ETCP. Ключевые архитектурные решения: - -| Решение | Обоснование | -|---------|-------------| -| **Однопоточный async** | Нет гонок данных, нет оверхеда на мьютексы, детерминированное поведение. Один `UASYNC` на поток | -| **Очередь-ориентированный дизайн** | Все взаимодействия между модулями — через lock-free очереди `ll_queue`. Callback-и + backpressure | -| **2 уровня надёжности** | ETCP (per-connection, RTT-based retrans) + etcp_router (per-service, 300ms retrans/17 попыток, transit forwarding) | -| **Memory pools** | Предварительно выделенные пулы для hot-path объектов (пакеты, ACK, payload). Нет malloc в критическом пути | -| **Детерминированный node_id** | `SHA256(privkey) → 63-bit` — глобально уникальный идентификатор без централизованного распределения | -| **2-phase cleanup** | Отложенное освобождение ресурсов через `uasync_call_soon` — защита от UAF при вызове close из callback | - -### Область применения - -- Site-to-site и client-to-site VPN -- Mesh-сети с автоматическим обнаружением топологии (BGP) -- Exit-прокси: SOCKS5, HTTP CONNECT, TCP/UDP/ICMP туннелирование -- P2P чат с синхронизацией (chatgui) -- Тестирование сетевых протоколов (dummynet — эмуляция потерь/задержек) - -### Ограничения - -- Требует root для TUN и raw socket (ICMP proxy) -- Однопоточная модель: CPU-bound операции блокируют event loop -- Нет аппаратного ускорения криптографии (OpenSSL софтварный) -- Максимум 16 hop-ов в BGP (защита от петель) -- Максимум 65535 фрагментов на пакет (нормализатор) - ---- - -## 2. Многослойный протокольный стек - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Layer 4: Приложения │ -│ routing (IP-форвардинг), proxy (SOCKS5/TCP/UDP/ICMP), chat, │ -│ db_sync (P2P БД), NTP, NAT │ -├─────────────────────────────────────────────────────────────────┤ -│ Layer 3: etcp_router — сервисная маршрутизация │ -│ seq-нумерация, дедупликация, реордеринг, ретрансмиссия, │ -│ inflight control, transit forwarding (multi-hop), minRTT │ -│ SVC_ROUTE_HDR: [cmd|dst|src|seq|svc_id|flags|timestamp] (25B) │ -├─────────────────────────────────────────────────────────────────┤ -│ Layer 2.5: LoadBalancer — multi-link балансировка │ -│ выбор линка (min inflight, round-robin), traffic shaper │ -│ (token bucket per-link), burst measurement │ -├─────────────────────────────────────────────────────────────────┤ -│ Layer 2: ETCP — надёжный транспорт │ -│ inflight queues (send_q / wait_ack), ACK processing, │ -│ retransmission (K1*rtt + K2*jitter), BBR congestion control, │ -│ фрагментация (pkt_normalizer), keepalive, INIT handshake │ -├─────────────────────────────────────────────────────────────────┤ -│ Layer 1: secure_channel — криптография │ -│ X25519 ECDH → AES-128-CCM (AEAD), Ed25519 signatures, │ -│ AES-128-CTR (streaming), pubkey obfuscation (INIT/PING) │ -│ Формат: nonce[13] || AES-CCM(plaintext || CRC32) || tag[16] │ -├─────────────────────────────────────────────────────────────────┤ -│ Layer 0: Транспорт │ -│ UDP (основной), TCP/STCP (fallback), TUN device, │ -│ кроссплатформенные абстракции (socket_compat, platform_compat) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Почему два уровня надёжности? - -**ETCP** обеспечивает надёжную доставку между двумя непосредственно соединёнными узлами (per-connection). Этого достаточно для прямого линка, но недостаточно когда трафик идёт через промежуточные узлы (transit). - -**etcp_router** добавляет end-to-end надёжность поверх ETCP для multi-hop сценариев: -- Свой sequence number — не зависит от ETCP seq (который per-hop) -- Transit forwarding: промежуточный узел получает `SVC_ROUTE_HDR`, видит что `dst_node_id != local_node_id` и пересылает дальше -- Собственный retransmission таймер (300ms, 17 попыток) — не ждёт ETCP retrans (который срабатывает на каждом hop-е отдельно) -- Per-service изоляция: `(remote_node_id, svc_id)` кортеж — разные сервисы имеют независимые seq - ---- - -## 3. Фундамент: однопоточный async (lib/) - -### 3.1 UASYNC — событийный цикл - -Центральный планировщик. Один инстанс на поток. - -``` -uasync_mainloop(): - while (!stop): - timeout = timeout_heap_peek() // ближайший таймер - epoll_wait/poll(timeout) // ждём события на сокетах - process expired timers // сработавшие таймеры - process ready sockets // сработавшие сокеты - process call_soon queue // отложенные callback (FIFO) - process posted tasks // межпоточные callback -``` - -**Ключевые API:** -- `uasync_set_timeout(ua, timeout_tb, arg, cb, name)` — таймер в 0.1ms единицах. Возвращает handle для отмены -- `uasync_add_socket(ua, fd, read_cb, write_cb, error_cb, arg)` — регистрация fd в epoll -- `uasync_call_soon(ua, arg, cb)` — отложенный вызов на следующей итерации (FIFO). Используется для 2-phase cleanup -- `uasync_post(ua, cb, arg)` — межпоточный вызов (thread-safe). Пробуждает epoll через wakeup pipe -- `uasync_memsync(ua)` — memory barrier для синхронизации памяти между потоками - -**Правила:** -- Никаких sleep/usleep — только таймеры -- Нельзя использовать из нескольких потоков один UASYNC -- `uasync_post` — единственный способ вызвать код uTun из другого потока - -### 3.2 ll_queue — lock-free очередь - -Основа всего межмодульного взаимодействия. **Критические правила:** - -1. **Callback правило:** `queue_set_callback(q, cb, arg)` → внутри cb обязательно: `queue_data_get(q)` → обработка → `queue_resume_callback(q)`. Без resume очередь навсегда блокируется. - -2. **Backpressure:** `queue_set_threshold(q, max_packets, max_bytes)` + `queue_waiter_wait(q, &handle, cb, arg)`. Не забиваем очередь — добавляем только когда waiter сработал. - -3. **Поиск по индексу:** `queue_data_put_with_index(q, entry)` — entry должен иметь ключ по смещению `index_offset` размером `index_size`. FNV-1a хеш. - -4. **Память:** `queue_entry_new_from_pool(pool)` / `queue_entry_free(entry)` для entry. `queue_dgram_free(entry)` отдельно для dgram. - -### 3.3 Memory pools - -Три пула в UTUN_INSTANCE для hot-path: - -| Пул | Размер блока | Назначение | -|-----|-------------|-----------| -| `data_pool` | `PACKET_DATA_SIZE` | Payload данные (фрагменты, recv_q, output_queue) | -| `pkt_pool` | `sizeof(ETCP_DGRAM) + PACKET_DATA_SIZE` | Исходящие wire-пакеты | -| `ack_pool` | `sizeof(ACK_PACKET)` | Записи ACK в ack_q | - -Пулы кэшируют до 64 освобождённых блоков (singly-linked free list). При исчерпании — fallback на malloc. Канарейки для детекции переполнения. - -### 3.4 Кроссплатформенность - -| Абстракция | Linux | FreeBSD | Windows | -|-----------|-------|---------|---------| -| Event loop | epoll | poll | poll | -| TUN | /dev/net/tun (ioctl) | /dev/tun (ioctl) | Wintun DLL | -| Роутинг | netlink (RTM_NEWROUTE) | routing socket | CreateIpForwardEntry | -| Сокеты | socket_t = int | socket_t = int | socket_t = SOCKET | -| Random | /dev/urandom | /dev/urandom | BCryptGenRandom | - ---- - -## 4. Сквозной путь данных - -### 4.1 Отправка: TUN → сеть - -``` -Ядро пишет IP-пакет в /dev/tun - │ - ▼ (epoll: fd готов к чтению) -┌──────────────────────────────────────────────┐ -│ tun_read_callback(fd, tun) │ tun_if.c -│ tun_platform_read() → сырой IP-пакет │ -│ ll_entry = pool_alloc(tun->pool) │ префикс [0x00] (cmd byte) -│ queue_data_put(tun->output_queue, entry) │ -└──────────────────────┬───────────────────────┘ - │ queue callback - ▼ -┌──────────────────────────────────────────────┐ -│ routing_pkt_from_tun_cb(q, instance) │ routing.c:207 -│ queue_data_get() → entry │ -│ route_pkt(instance, entry, SELF_NODE_ID) │ -│ queue_resume_callback() │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ route_pkt(instance, entry, src_node_id) │ routing.c:53 -│ extract_dst_ip(entry) → dst_ip │ парсинг IPv4 заголовка -│ route_lookup(rt, dst_ip) → ROUTE_ENTRY │ longest prefix match -│ if v_node_info == NULL (локальный): │ -│ → queue_data_put(tun->input_queue) │ доставка себе -│ else (удалённый узел): │ -│ → etcp_route_send(inst, node_id, entry) │ отправка через ETCP -└──────────────────────┬───────────────────────┘ - │ если удалённый - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_route_send(inst, dst, entry) │ etcp_router.c -│ rconn = etcp_router_conn_get(dst, svc_id) │ per-(dst,svc) соединение -│ if rconn->inflight >= inflight_limit: │ -│ → queue_data_put(rconn->send_q) │ backpressure -│ return │ -│ router_send_one(rconn, data, len): │ -│ SVC_ROUTE_HDR hdr = {cmd, dst, src, │ 25 байт -│ tx_seq++, svc_id, flags, timestamp} │ -│ ll_entry = [hdr || payload] │ -│ entry_copy = copy for inflight_q (retrans)│ -│ etcp_send(conn, entry) │ -│ start retrans_timer (300ms) │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_send(conn, entry) │ etcp_api.c -│ queue_data_put(conn->send_input_q, entry) │ == normalizer->input -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ pkt_normalizer — packer │ pkt_normalizer.c -│ waiter на etcp->input_queue │ backpressure: ждём пустую очередь -│ etcp_input_ready_cb(): │ -│ pop from pn->input │ -│ write [total_len:2 LE][data] in pn->data │ буфер аккумуляции -│ when buffer >= frag_size: │ -│ pn_send_to_etcp() → ETCP_FRAGMENT │ -│ queue_data_put(etcp->input_queue) │ -│ pn_flush_cb(): flush partial buffer │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ etcp — inflight processing │ etcp.c -│ input_queue_cb(): │ -│ pop ETCP_FRAGMENT from input_queue │ -│ wrap in INFLIGHT_PACKET (inflight_pool) │ -│ assign next_tx_id++ │ -│ queue_data_put_with_index(input_send_q) │ хеш-индекс по seq -│ │ -│ input_send_q_cb → process_send_queue(): │ -│ loop: │ -│ etcp_request_pkt(etcp): │ -│ select link (loadbalancer) │ -│ pop from input_send_q │ -│ move to input_wait_ack │ -│ build wire packet: │ -│ [ACK section (0x01)] │ mandatory, always first -│ [optional: MEAS_RESP, TIMESTAMP, │ -│ MEAS_TS, FILLER] │ -│ [PAYLOAD (0x00): seq(4B) + data] │ always last -│ etcp_loadbalancer_send(dgram) │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ LoadBalancer → secure_channel → UDP │ -│ select_link(): min inflight_bytes, RR ties │ -│ check shaper (token bucket) │ -│ etcp_encrypt_send(dgram): │ -│ sc_encrypt(header(3) + data) │ AES-128-CCM -│ append CRC32 │ -│ socket_sendto(sock, ...) │ -│ update shaper_load_time_tb │ -└──────────────────────────────────────────────┘ -``` - -### 4.2 Приём: сеть → TUN - -``` -UDP socket recvfrom() - │ - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_connections_read_callback_socket() │ etcp_connections.c -│ sc_decrypt() → проверка CRC32 │ -│ if INIT_REQUEST: process handshake │ -│ if INIT_RESPONSE: complete handshake │ -│ if KEEPALIVE/PING/PONG: handle │ -│ if data (ETCP sections): │ -│ → etcp_conn_input(pkt) │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_conn_input(pkt) │ etcp.c -│ for each section in pkt: │ -│ ACK (0x01): │ -│ parse cumulative rx_ack_till │ всё до этого seq собрано -│ for each individual ACK: │ -│ find in input_wait_ack by seq │ -│ remove, update BBR (rate_sample) │ -│ free data_pool memory │ -│ advance tx state if window opens │ -│ TIMESTAMP (0x06): │ -│ compute RTT, update jitter │ -│ PAYLOAD (0x00): │ -│ extract seq from data[1..4] │ -│ create ACK entry in ack_q │ будет piggybacked в ответ -│ if seq == last_delivered_id + 1: │ -│ move directly to output_queue │ -│ else: │ -│ store in recv_q (hash by seq) │ OOSEQ буфер -│ etcp_output_try_assembly(): │ -│ scan recv_q for contiguous seq │ -│ move to output_queue │ -└──────────────────────┬───────────────────────┘ - │ output_queue callback - ▼ -┌──────────────────────────────────────────────┐ -│ pkt_normalizer — unpacker │ pkt_normalizer.c -│ pn_unpacker_cb(): │ -│ pop ETCP_FRAGMENT from output_queue │ -│ read [total_len:2 LE] header │ -│ accumulate in recvpart buffer │ -│ when recvpart complete: │ -│ queue_data_put(pn->output, packet) │ -└──────────────────────┬───────────────────────┘ - │ pn->output callback - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_int_recv(queue, conn) │ etcp_api.c -│ read cmd = data[0] │ -│ dispatch: bindings->callbacks[cmd](conn, e)│ -│ cmd=0x03 (ETCP_ID_SVC_ROUTE): │ -│ → etcp_router_recv_cb() │ -│ cmd=0x00 (ETCP_ID_DATA): │ -│ → direct routing handler (legacy) │ -└──────────────────────┬───────────────────────┘ - │ для cmd=0x03 (SVC_ROUTE) - ▼ -┌──────────────────────────────────────────────┐ -│ etcp_router_recv_cb(conn, entry) │ etcp_router.c -│ parse SVC_ROUTE_HDR: │ -│ dst_node_id, src_node_id, seq, svc_id │ -│ │ -│ if dst_node_id != local_node_id: │ -│ router_forward_transit(): │ мы — промежуточный узел -│ find/create TRANSIT_QUEUE(src,dst) │ -│ queue_data_put(transit_q, entry) │ -│ drain transit_q → next hop via │ -│ waiter on send_input_q │ backpressure -│ return │ -│ │ -│ if payload_len == 0: │ это ACK/CLOSE/RST -│ router_handle_ack(rconn, hdr): │ -│ tx_acked = hdr->seq │ продвигаем окно -│ drain send_q │ освободившиеся слоты -│ return │ -│ │ -│ router_handle_data_packet(rconn, ...): │ -│ check bounds, dedup (recv_q lookup) │ -│ queue_data_put(rconn->incoming_q) │ -│ if hdr->seq == rconn->rx_seq + 1: │ следующий ожидаемый -│ router_try_assembly(rconn): │ -│ scan recv_q for contiguous seq │ -│ deliver to service callback │ -└──────────────────────┬───────────────────────┘ - │ service callback (ETCP_RT_ID_DATA) - ▼ -┌──────────────────────────────────────────────┐ -│ routing_pkt_from_etcp_cb(conn, pkt) │ routing.c:180 -│ route_pkt(instance, pkt, conn->peer_node) │ -│ → route_lookup() → если локальный: │ -│ → queue_data_put(tun->input_queue) │ -└──────────────────────┬───────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────┐ -│ tun_input_queue_callback(q, tun) │ tun_if.c:88 -│ queue_data_get() → pkt │ -│ tun_platform_write(pkt->dgram+1, len) │ пишем в /dev/tun -│ queue_dgram_free + queue_entry_free │ -│ queue_resume_callback() │ -└──────────────────────────────────────────────┘ - │ - ▼ -Ядро читает IP-пакет из /dev/tun → доставка приложению -``` - -### 4.3 Retransmission (два уровня) - -**ETCP retrans** (`ack_timeout_check`): -``` -Таймер сканирует input_wait_ack с головы. -Для каждого INFLIGHT_PACKET: - if now - last_timestamp > timeout: - timeout = RTT * K1 + jitter * K2 - min 50, max 10000 (0.1ms единицы) - → переместить обратно в input_send_q (переотправка) -``` - -**Router retrans** (`router_retrans_timer_cb`): -``` -300ms начальный таймаут -Экспоненциальный backoff (×2 каждые 3 попытки) -Максимум 17 попыток (суммарно ~5 секунд) -Копии пакетов хранятся в inflight_q (hash по seq) -При успешном ACK: удаление из inflight_q -``` - ---- - -## 5. ETCP — надёжный транспорт (Layer 2) - -### 5.1 Основные структуры - -**ETCP_CONN** — одно логическое соединение с удалённым узлом: -- Идентичность: `peer_node_id` (uint64_t), `session_id` (uint32_t) -- Криптография: `crypto_ctx` (sc_context_t — сессионные ключи) -- Фрагментация: `normalizer` (PKTNORM*) -- Линки: связный список `ETCP_LINK*` -- Счётчик seq: `next_tx_id` (монотонный), `last_rx_id`, `last_delivered_id` -- RTT: `rtt_last`, `rtt_avg_10`, `rtt_avg_100`, `jitter`, `rtt_history[10]` -- Состояние: 0=pending, 1=ready, 2=deleted (2-phase cleanup) - -**ETCP_LINK** — один UDP-путь: -- Статус: `recv_keepalive` (принимаем ли пакеты), `remote_keepalive` (видит ли нас пир), `link_status` -- BBR: `bbr` (struct bbr*), `delivered_bytes`, `pacing_rate`, `bandwidth` -- Flow control: `inflight_bytes`, `inflight_packets`, `inflight_lim_bytes` -- Shaper: `shaper_load_time_tb`, `shaper_sub_nanotime`, `shaper_timer` -- NAT: `nat_type`, `nat_ip`, `nat_port` - -**Wire-формат пакета:** -``` -[encrypted:] - [timestamp:2] [flag_up:1] // ETCP_ENCRYPTED_HDR = 3 байта - [section_0] [section_1] ... [section_N] // типизированные секции -[appended after encryption:] - [CRC32:4] - -Sections: - ACK (0x01): [count:1][rx_ack_till:4][rx_dup:2][per-ACK: seq(4)+ts(2)+delay(2)] - PAYLOAD (0x00): [seq:4 BE][data...] (always last) - TIMESTAMP (0x06): [cur_ts:2][txrx_offset:2] (RTT measurement) - MEAS_TS (0x07): [burst_id:2][flags:1][seq:1][ts_us:2][sz:2] (burst) - MEAS_RESP (0x08): [burst_id:2][valid:1][gap_avg:4][gap_min:4][pkt_cnt:1] - FILLER (0x09): [len:2][zeros...] (burst padding) - METRICS (0x0A): [csv_len:2][Ed25519_sig:64][csv...] -``` - -### 5.2 INIT handshake - -Клиент знает pubkey сервера из конфига. Сервер не знает клиента заранее. - -``` -Client Server - │ │ - │ 1. ECDH: priv_C × pub_S → session_key │ - │ 2. salt = random(8) │ - │ 3. obf_pub = XOR(pub_C, │ - │ SHA256(salt || pub_S)) │ - │ 4. encrypt INIT_REQUEST │ - │ (AES-CCM, session_key) │ - │ │ - │──── INIT_REQ + [salt||obf_pub] ──────→│ - │ │ 1. deobf: XOR(obf_pub, - │ │ SHA256(salt || pub_S)) - │ │ → pub_C recovered - │ │ 2. ECDH: priv_S × pub_C - │ │ → same session_key - │ │ 3. decrypt INIT_REQUEST - │ │ (проверка: клиент знает pub_S) - │ │ 4. create ETCP_CONN - │ │ 5. encrypt INIT_RESPONSE - │←────── INIT_RESP + [salt||obf_pub] ───│ - │ │ - │ decrypt RESPONSE, complete │ - │ link_state = connected │ - │ │ - │←══════ keepalive/данные ═════════════→│ -``` - -**Ключевое свойство обфускации:** `XOR(XOR(pub_C, SHA256(salt||pub_S)), SHA256(salt||pub_S)) = pub_C`. Обе стороны знают `pub_S` и `salt`, поэтому обе могут восстановить `pub_C`. Пассивный наблюдатель видит только случайный XOR. - -### 5.3 BBR congestion control (per-link) - -Алгоритм управления перегрузкой, работающий на уровне ETCP_LINK. - -**Режимы:** -``` -STARTUP → экспоненциальный рост до обнаружения BDP - ↓ (найден BDP) -DRAIN → слив избыточного inflight - ↓ -PROBE_BW → циклическое зондирование bandwidth: - CRUISE (без изменений) → REFILL (увеличение) → - UP (зондирование вверх) → DOWN (слив) → CRUISE... - ↓ (периодически) -PROBE_RTT → зондирование минимального RTT (inflight=4) -``` - -**Оценка bandwidth:** два окна — `bw_hi` (максимальный фильтр, окно 10) и `bw_lo` (минимум из окна 6). Используется fixed-point арифметика (BBR_SCALE=8, BW_SCALE=24). - -**Pacing rate:** `pacing_rate = bw * pacing_gain`. Пакеты отправляются не чаще чем `pkt_size / pacing_rate`. - -**Burst measurement:** Когда inflight достигает лимита и >500ms с последнего burst, отправляется 12 пакетов подряд (bypass shaper). Получатель измеряет inter-packet gaps и возвращает min/avg gap. Отправитель вычисляет: `BW = pkt_size * 8000 / gap_min_us`. - ---- - -## 6. etcp_router — сервисная маршрутизация (Layer 3) - -### 6.1 Зачем нужен поверх ETCP - -ETCP обеспечивает надёжность только на одном hop-е. etcp_router добавляет: - -1. **End-to-end seq:** не зависит от per-hop ETCP seq -2. **Transit forwarding:** пакет может пройти через несколько промежуточных узлов -3. **Per-service изоляция:** разные сервисы имеют независимые seq и inflight control -4. **Inflight control:** свой congestion window (max_inflight, default 256) -5. **minRTT probing:** периодически снижает inflight до 4 для измерения минимального RTT - -### 6.2 Transit forwarding - -``` -Узел A → Узел B (промежуточный) → Узел C (цель) - -A: etcp_route_send(inst, C_node_id, data) - → topo_group_find_conn_for_node(C_node_id) → conn_AB - → router_send_one(rconn_AC): SVC_ROUTE_HDR {dst=C, src=A, seq=5} - -B: etcp_router_recv_cb(): - dst_node_id == C → не нам - router_forward_transit(): - TRANSIT_QUEUE tq(src=A, dst=C) → queue_data_put - drain через conn_BC → etcp_send(conn_BC, entry) - (SVC_ROUTE_HDR не модифицируется, только пересылается) - -C: etcp_router_recv_cb(): - dst_node_id == C → нам - router_handle_data_packet() - → incoming_q → recv_q (reorder) → service callback -``` - -Backpressure на транзите: если `conn_BC->send_input_q` заполнена, transit queue ждёт освобождения через waiter. - -### 6.3 Inflight control и retrans - -``` -Отправка: - tx_seq - tx_acked >= inflight_limit → блокировка → send_q - tx_seq - tx_acked < inflight_limit → router_send_one() → inflight_q + retrans_timer - -ACK: - получаем SVC_ROUTE_HDR с seq=N, payload_len=0 - → tx_acked = N - → удаляем все inflight_q записи с seq <= N - → drain send_q (освободившиеся слоты) - -Retrans: - 300ms → 600ms → 1200ms → ... → максимум 17 попыток - router_retransmit_one(): копия из inflight_q → etcp_send() - если 17 попыток без ACK → закрытие rconn -``` - ---- - -## 7. BGP топология - -### 7.1 Модель данных - -- **TOPO_NODE:** pubkey, ed25519_pubkey, имя, версия, флаги -- **TOPO_NODEQ:** узел в группе + пути (TOPO_NODEPATH), подсети, connectivity, transit -- **TOPO_NODEPATH:** путь к узлу (next_hop_node_id, hop_count, hop_list до 16) -- **TOPO_GROUP:** группа узлов (UTUN — VPN с подсетями, CHAT — чат без подсетей) - -### 7.2 Поток синхронизации - -``` -Новое ETCP соединение установлено - │ - ▼ -topo_group_new_conn(group, conn): - 1. topo_group_add_to_senders() // добавить в список BGP-пиров - 2. topo_group_send_table_request(conn) // запросить полную таблицу - │ - ▼ (удалённая сторона получает REQUEST_TABLE) -topo_group_handle_request_table(): - 1. topo_group_send_nodeinfo(local_node) // отправить информацию о себе - 2. topo_group_send_full_table() // все известные узлы - ├── for each node in group->nodes: - │ if peer NOT in node's hop_list: - │ topo_group_send_nodeinfo(node, conn) - 3. topo_group_send_table_complete() -``` - -### 7.3 Обработка NODEINFO - -``` -topo_group_process_nodeinfo(group, from_conn, data, len): - - 1. Проверка типа группы (UTUN/CHAT) - 2. Проверка hop_count < MAX_HOPS (16) - 3. Проверка версии: - if existing_node.version >= new_node.version: - только обновить пути (множественные пути к узлу) - return - - 4. Полное обновление: - - десериализация TOPO_NODE (адреса, подсети, hop_list) - - создание/обновление TOPO_NODEQ в group->nodes (hash queue) - - добавление пути: from_conn->peer_node_id в hop_list - - ROUTE_INSERT(instance->rt, node) ← ПОДСЕТИ В ТАБЛИЦУ МАРШРУТИЗАЦИИ! - - персистентность в SQLite - - уведомление control_server + chatgui - - 5. BROADCAST соседям: - for each sender in senders_list: - if sender->peer_node_id not in node->hop_list: - topo_group_send_nodeinfo(node, sender->conn) -``` - -### 7.4 WITHDRAW (удаление узла) - -``` -topo_group_remove_conn(group, conn): - for each node reachable ONLY through this conn: - topo_group_process_withdraw(group, node): - route_delete(instance->rt, node) ← удаление из таблицы маршрутов - broadcast WITHDRAW соседям - удаление из group->nodes -``` - ---- - -## 8. conn_mgr — трёхфазное установление соединений - -### 8.1 Диаграмма фаз - -``` -conn_mgr_connect_node(mgr, target_node_id) - │ - ├── Фаза 1: DIRECT ─────────────────────────┐ - │ Для каждого адреса target × наш сокет: │ - │ etcp_connection_create() │ - │ etcp_link_new() → INIT │ - │ таймер 5000ms │ - │ │ - │ ✓ Успех → CONN_MGR_OK (DIRECT) │ - │ ✗ Таймаут: │ - │ if (у нас прямой IP) && (!у target): │ - │ → Фаза 2 (REVERSE) │ - │ else: │ - │ → Фаза 3 (INDIRECT) │ - │ │ - ├── Фаза 2: REVERSE ─────────────────────────┤ - │ etcp_route_send(DIRECT_REQ) → через BGP │ - │ { наши адреса для подключения } │ - │ таймер 15000ms │ - │ │ - │ Target получает DIRECT_REQ: │ - │ etcp_connection_create() │ - │ etcp_link_new(REVERSE) → INIT к нам │ - │ │ - │ ✓ Успех → CONN_MGR_OK (REVERSE) │ - │ ✗ Таймаут → Фаза 3 (INDIRECT) │ - │ │ - └── Фаза 3: INDIRECT ────────────────────────┤ - etcp_route_send(EXCHANGE_REQ) → BGP │ - { наши top-4 кандидата + RTT } │ - таймер 15000ms │ - │ - Target: probe наши кандидаты │ - → EXCHANGE_RESP {свои кандидаты + RTT} │ - │ - Мы: probe кандидаты target │ - cm_compute_intermediaries(): │ - union списков │ - total_rtt = our_rtt + their_rtt │ - sort by total_rtt, select top 3 │ - → INTERM_SELECTED │ - entry->conn_type = INDIRECT │ - CONN_MGR_OK │ -``` - -### 8.2 Фоновые процессы - -- **bg_ping_timer:** каждые ~100ms пингует очередной BGP-узел (RTT измерение), полный цикл ≥10s -- **candidate_ping_timer:** каждые ~2s обновляет RTT для best_candidates[3] -- **idle_timer:** каждые ~1s проверяет неактивные соединения, дисконнект по idle_timeout - -### 8.3 NAT compatibility check - -При выборе адресов для DIRECT фазы проверяется совместимость NAT типов: -- Если у нас EIM NAT: можем соединиться с кем угодно -- Если у нас Symmetric NAT и target за NAT: DIRECT невозможен (→ REVERSE/INDIRECT) -- NAT тип определяется через сравнение src_ipv4 (что мы о себе сообщаем) и наблюдаемого адреса при INIT - ---- - -## 9. Криптография - -### 9.1 Генерация ключей и идентичности - -``` -X25519 keypair: - EVP_PKEY_keygen(EVP_PKEY_X25519) - → private_key[32], public_key[32] - -Node ID (63-bit): - SHA256(private_key) → hash[32] - node_id = *((uint64_t*)hash) & 0x7FFFFFFFFFFFFFFF - -Ed25519 keypair (производный от X25519): - SHA512(private_key) → hash[64] - ed25519_privkey = hash[0..32] - ed25519_pubkey = EVP_PKEY_get_raw_public_key(ed25519_privkey) -``` - -### 9.2 Сессионный ключ - -``` -X25519 ECDH: - shared_secret = EVP_PKEY_derive(my_priv, peer_pub) // 32 байта - -Session key (AES-128): - SHA256(shared_secret || "uTun-v3-session") → hash[32] - session_key = hash[0..16] -``` - -### 9.3 Формат зашифрованного пакета - -``` -Шифрование: - nonce = sc_build_nonce(tx_counter) // 13 байт: SHA256(urandom||counter||time) - AES-128-CCM(nonce, session_key): - encrypt: header[3] + plaintext[N] + CRC32[4] - tag = CCM auth tag[16] - -Пакет: - [nonce:13] [ciphertext: N+7] [tag:16] [+ pubkey_block:40 для INIT] - -Расшифровка: - извлечь nonce[13] из начала, tag[16] из конца - AES-128-CCM decrypt - проверить CRC32 - проверить CCM auth tag (EVP_DecryptFinal_ex) -``` - -### 9.4 Streaming cipher (STCP) - -``` -AES-128-CTR: - iv = SHA256(session_key || stream_id_LE || "uTun3-stream")[0..16] - EVP_aes_128_ctr(session_key, iv) - - XOR in-place (конфиденциальность без аутентификации) - STCP добавляет свой CRC32 на уровне фреймов -``` - -### 9.5 Ed25519 подписи - -Используются для: -- Метрик (METRICS section 0x0A) — подписанные CSV -- Сообщений чата (MSG_OPT_SIGNED флаг) -- db_sync записей (JSON + Ed25519 sig) - -Стриминговая подпись: `SHA512(SHA512(data))` — обе стороны аккумулируют SHA-512, затем one-shot Ed25519 над финальным хешем. - ---- - -## 10. Прокси-система - -### 10.1 Архитектура клиента (tcp_proxy_client) - -``` -Приложение (браузер и т.д.) - │ подключается к SOCKS5 localhost:1080 - ▼ -┌──────────────────┐ -│ socks_proxy │ SOCKS5/HTTP CONNECT парсинг -│ (src/proxy/) │ → резолвинг destination -└────────┬─────────┘ - │ ETCP CONNECT → tcp_proxy_server на exit node - ▼ -┌──────────────────┐ -│ lwip_tcp │ Встроенный TCP стек -│ (src/lwip_tcp/) │ Завершает TCP на клиенте -│ │ tcp_write() → сегменты -└────────┬─────────┘ - │ lwIP output callback → TUN write - ▼ -┌──────────────────┐ -│ TUN (nat_tun) │ Локальный виртуальный интерфейс -│ │ Перехватывает ВЕСЬ исходящий TCP -└────────┬─────────┘ - │ для не-TCP: udp_proxy / icmp_proxy - │ для TCP: lwip_tcp_input() → tcp_process() - ▼ - ETCP туннель → exit node -``` - -**Ключевая особенность:** клиент использует встроенный lwIP TCP стек для терминации TCP-соединений локально. Это позволяет: -- Не гонять TCP ACK через туннель (нет TCP-over-TCP проблемы) -- Прозрачно обрабатывать потери на пути клиент↔exit (ETCP надёжен) -- Поддерживать SOCKS5/HTTP CONNECT без модификации приложений - -### 10.2 Архитектура сервера (tcp_proxy_server) - -``` -ETCP туннель от клиента - │ CONNECT subcmd: target_ip:port - ▼ -┌──────────────────┐ -│ tcp_proxy_server │ Exit node -│ │ Создаёт OS TCP сокет к реальному адресату -│ │ Релеит данные: ETCP ↔ OS TCP -│ │ Backpressure: tx_buf + pause_waiter + retry_timer -│ │ FIN handling: отложенный FIN до опустошения read_queue -└────────┬─────────┘ - │ OS TCP connect() - ▼ - Целевой сервер в интернете -``` - -### 10.3 lwIP TCP стек - -Встроенный в uTun TCP/IP стек (адаптированный lwIP): - -- **Context-based:** один `lwip_tcp_ctx` на tcp_proxy_client -- **Таймеры:** `tcp_fasttmr()` (delayed ACK) и `tcp_slowtmr()` (retrans, keepalive, TIME_WAIT) — каждые 250ms -- **4 списка PCB:** bound, listen, active, time_wait -- **10 TCP состояний** (RFC 793): CLOSED → LISTEN → SYN_RCVD → ESTABLISHED → FIN_WAIT_1/2 → TIME_WAIT -- **Reno congestion control:** slow start, congestion avoidance, fast retransmit (3 dupack), fast recovery -- **Memory pools:** pcb_pool, pcb_listen_pool, seg_pool -- **Trace ring buffer:** 384 записи внутренних событий для отладки -- **Конфигурация:** MSS=1460, RTO_MIN=3000ms, MSL=60s, TCP_WND=8*MSS - ---- - -## 11. Модель потоков - -### 11.1 Основной процесс uTun (однопоточный) - -``` -Поток 1 (главный): - uasync_mainloop() - ├── Сокеты: - │ ├── UDP listen (все [server] сокеты) - │ ├── TCP listen (control_server, STCP) - │ └── TUN fd (read/write через uasync callback) - ├── Таймеры: - │ ├── ETCP: retrans, ack_response, keepalive - │ ├── etcp_router: retrans, ACK, no_route_retry, minRTT_probe - │ ├── BBR: shaper per-link - │ ├── conn_mgr: bg_ping, candidate_ping, idle, connect_timeout - │ ├── Нормализатор: flush_timer - │ ├── Прокси: диагностика (1s), retry (500ms) - │ ├── NTP: resync - │ └── db_sync: TTL cleanup - ├── call_soon (FIFO): - │ └── Отложенные callback (2-phase cleanup, resume) - └── post (межпоточные): - └── Из chatgui/GUI потока - -НЕТ: sleep, usleep, pthread_mutex_lock (в главном потоке) -ВСЕ: асинхронно через uasync -``` - -### 11.2 chatgui (двухпоточный) - -``` -Поток 1 (Qt main thread): - - Рендеринг UI (QPainter) - - Обработка событий (клики, ввод) - - Чтение из SQLite (read-only) - - Отправка команд в uTun через GuiBridge (Qt signals) - -Поток 2 (uTun networking): - - uasync_mainloop() - - ETCP соединения - - ChatSync (P2P синхронизация каналов) - - Запись в SQLite (thread-safe write) - - GuiBridge: получение команд через uasync_post + wakeup pipe -``` - -**Правила межпоточного взаимодействия:** -- Из GUI → uTun: `uasync_post(ua, callback, arg)` — thread-safe -- Из uTun → GUI: `emit Qt signal` — Qt thread-safe через queued connection -- Память: `uasync_memsync(ua)` перед post для видимости изменений -- БД: chat_core (uTun поток) пишет, DbManager (GUI поток) читает - -### 11.3 Windows TUN (дополнительный поток) - -На Windows TUN чтение выполняется в отдельном потоке `tun_read_thread_proc` (Wintun API требует dedicated thread). Результаты передаются в главный поток через `uasync_post`. - ---- - -## 12. Жизненный цикл UTUN_INSTANCE - -### 12.1 Инициализация - -``` -utun_instance_create(ua, config_path): - config_ensure_keys_and_node_id() // авто-генерация ключей при первом запуске - parse_config(config_path) // парсинг INI в utun_config - debug_set_output_file() // открытие лог-файла - debug_set_level() per config // настройка уровней отладки - - instance_init_common(instance, ua, config): - 1. Копирование name, node_id из конфига - 2. sc_init_local_keys() // загрузка X25519 ключей - 3. sc_derive_ed25519_pubkey() // деривация Ed25519 ключей - 4. queue_new() для networks // хеш-очередь сетей - 5. queue_new() для connections // хеш-очередь ETCP соединений - 6. memory_pool_init() ×3 // data_pool, pkt_pool, ack_pool - 7. routing_create(instance) // таблица маршрутов - 8. tun_init() // TUN интерфейс - 9. init_sockets(instance) // UDP сокеты из [server] - 10. topo_groups_init(instance) // BGP группы + SQLite - 11. topo_group_update_my_nodeinfo() // локальный узел - 12. conn_mgr_init(instance) // менеджер соединений - 13. fw_init() + fw_load_rules() // firewall - 14. etcp_router_init(instance) // сервисный роутер - 15. routing_bind(instance) // привязка data handler - 16. tcp_proxy_server_init() // exit node прокси - 17. tcp_proxy_client_create() // клиентский прокси (если включён) - -utun_instance_init(instance): - 1. db_sync_init(instance) // распределённая БД - 2. routing_set_tun(instance) // подключение TUN к роутингу - 3. nat_transport_init(instance) // NAT транспорт - 4. init_connections(instance) // ETCP соединения из [client] - 5. control_server_init() // мониторинг - 6. running = 1 - 8. ntp_time_init() // NTP синхронизация - 9. ntp_node_time_init() // межузловое время -``` - -### 12.2 Завершение (строго обратный порядок) - -``` -utun_instance_destroy(instance): - 1. running = 0 - 2. Отмена NTP таймеров - 3. control_server_destroy() // потом мониторинг - 5. Закрытие всех UDP сокетов // etcp_socket_remove() для каждого - 6. db_sync_destroy() - 7. etcp_connection_close() для всех соединений в connections - 8. uasync_poll() до опустошения deferred callbacks - 9. Отмена pending_pings - 10. tun_route_del_all() + tun_close() // TUN - 11. tcp_proxy_client_destroy() - 12. tcp_proxy_server_destroy() - 13. routing_destroy() - 14. nat_transport_destroy() - 15. etcp_router_destroy() - 16. conn_mgr_destroy() - 17. topo_groups_destroy() - 18. fw_free() - 19. stcp_link_server_destroy() - 20. networks queue очистка - 21. free_config() // освобождение конфига - 22. memory_pool_destroy() ×3 // pkt_pool, ack_pool, data_pool (порядок важен!) - 23. u_free(instance) -``` - -### 12.3 2-phase cleanup ETCP_CONN - -``` -etcp_connection_close(etcp): - Phase 1: - - очистка таймеров - - уничтожение линков (etcp_link_free) - - уничтожение нормализатора - - удаление из instance->connections - - state = 2 (deleted) - - Phase 2 (if ref_count == 0): - - uasync_call_soon(etcp_connection_free_deferred) - - на следующей итерации event loop: - - освобождение всех очередей - - освобождение crypto_ctx - - u_free(etcp) - -Зачем 2 фазы: - - close может быть вызван из callback того же ETCP_CONN - - нельзя освобождать память пока мы ещё в стеке вызова callback - - call_soon гарантирует что стек полностью размотается перед free -``` - -### 12.4 Reload - -``` -utun_instance_reload(instance, new_config_path): - - парсинг нового конфига - - сравнение [server] секций: удалить удалённые, добавить новые - - сравнение [client] секций: закрыть ненужные соединения, открыть новые - - обновление firewall, NAT, keepalive параметров - - замена instance->config → free_config(old) -``` - ---- - -## Приложение A: Полный список очередей в системе - -| Модуль | Очередь | Тип | Назначение | -|--------|---------|-----|-----------| -| tun_if | `tun->output_queue` | FIFO | TUN → routing | -| tun_if | `tun->input_queue` | FIFO | routing → TUN | -| routing | `routing_pkt` (через etcp_router) | — | IP-пакеты на маршрутизацию | -| pkt_normalizer | `pn->input` | FIFO | Пакеты на фрагментацию | -| pkt_normalizer | `pn->output` | FIFO | Собранные пакеты | -| etcp | `send_input_q` | FIFO | Вход от нормализатора | -| etcp | `input_queue` | FIFO | Фрагменты → inflight | -| etcp | `input_send_q` | hash(seq) | Готовые к отправке | -| etcp | `input_wait_ack` | hash(seq) | Отправленные, ждут ACK | -| etcp | `recv_q` | hash(seq) | Принятые не по порядку | -| etcp | `output_queue` | FIFO | Собранные данные | -| etcp | `ack_q` | hash(seq) | Неотправленные ACK | -| etcp | `transit_queues` | hash(src,dst) | Транзитные пакеты | -| etcp_router | `rconn->incoming_q` | FIFO | Входящие router-пакеты | -| etcp_router | `rconn->recv_q` | hash(seq) | Реордеринг router-пакетов | -| etcp_router | `rconn->send_q` | FIFO | Заблокированные на отправку | -| etcp_router | `rconn->inflight_q` | hash(seq) | Копии для ретрансмиссии | -| topo_group | `group->nodes` | hash(node_id) | Все известные узлы | -| topo_group | `group->senders_list` | linked list | BGP-пиры | -| utun_instance | `instance->connections` | hash(peer_node_id) | Все ETCP_CONN | -| utun_instance | `instance->networks` | hash(network_id) | Доверенные сети | -| conn_mgr | `mgr->entries[]` | dynamic array | Состояния подключений | - -## Приложение B: Основные таймауты и константы - -| Константа | Значение | Где используется | -|-----------|---------|-----------------| -| ETCP retrans min | 5ms (50 tb) | etcp ack_timeout_check | -| ETCP retrans max | 1000ms (10000 tb) | etcp ack_timeout_check | -| ETCP delayed ACK | 2ms (20 tb) | etcp ack_response_timer | -| Router retrans init | 300ms | etcp_router retrans_timer | -| Router retrans max tries | 17 (~5s) | etcp_router | -| Router minRTT probe interval | 10s | etcp_router | -| Keepalive interval (active) | 200ms | etcp_connections | -| Keepalive interval (idle) | 10s | etcp_connections | -| INIT retrans init | 500ms | etcp_connections | -| INIT retrans max | 3200ms | etcp_connections | -| conn_mgr DIRECT timeout | 5s | conn_mgr | -| conn_mgr REVERSE/INDIRECT timeout | 15s | conn_mgr | -| Normalizer frag_size | mtu - ACK_RESERVE - UDP_HDR - SC_HDR | ~1553 bytes | -| BBR burst packets | 12 | etcp burst measurement | -| BBR max cwnd | 1MB (configurable) | etcp_bbr | -| lwIP TCP_RTO_MIN | 3000ms | lwip_tcp | -| lwIP TCP_MSL | 60s | lwip_tcp | -| UDP proxy flow expiry | 60s | udp_proxy | -| ICMP proxy request timeout | 5s | icmp_proxy | -| db_sync record batch | 32 | db_sync | -| BGP max hops | 16 | topo_group | -| BGP MAX_ADDR_TYPES | 16 | topo_node | - -## Приложение C: Ключевые callback-цепочки - -### C.1 Новое ETCP соединение - -``` -etcp_connection_create() - → new_conn_cbks chain (topo_group_etcp_conn_cbk, ...) - → topo_group: up/down callbacks on connection - → etcp_conn_queue_set_ready() - → ready_cbks chain - -etcp_on_up() - → up_cbks chain (topo_group_on_conn_up, conn_mgr notification, ...) - → topo_group_on_conn_up: BGP full table sync -``` - -### C.2 Получение данных (снизу вверх) - -``` -UDP recv → sc_decrypt - → etcp_conn_input: parse sections, ACK processing, store in recv_q - → etcp_output_try_assembly: output_queue - → pn_unpacker_cb: reassemble - → etcp_int_recv: dispatch by cmd byte - → cmd=0x03: etcp_router_recv_cb - → router_handle_data_packet - → incoming_q callback - → router_try_assembly - → service callback (routing_pkt_from_etcp_cb, proxy, chat, ...) -``` - -### C.3 Отправка данных (сверху вниз) - -``` -service (routing, proxy, chat) - → etcp_route_send / etcp_router_conn_send - → router_send_one: SVC_ROUTE_HDR + inflight_q - → etcp_send: send_input_q - → pkt_normalizer packer: fragment - → etcp->input_queue - → input_queue_cb: INFLIGHT_PACKET - → input_send_q - → process_send_queue: etcp_request_pkt - → loadbalancer_select_link - → etcp_encrypt_send: sc_encrypt - → socket_sendto -``` - ---- - -*Документ основан на анализе исходного кода uTun. Для каждого модуля доступен отдельный `_doc.md` файл с детальным описанием API.* +Архитектура проекта + +Общая идея - децентрализованный модуль коммуникации, основанный на группах создаваемых самими пользователями, который включает модули раширения такие как: + - групповой чат с поддержкай медиа и голосовых сообщений (референс - телега) + - расширение чата - рация (референс - zello) + - расширение медиа - realtime CDN / трансляции + + +1. Модуль подключения p2p (ETCP/STCP) +Позволяет подключиться двум узлам друг к другу. Весь трафик шифруется. Для подключения нужен публичный ключ удаленного узла. +Позволяет использовать несколько линков до узла параллельно и динамически их переключать (агрегация/балансировка нагрузки/failover) + +2. Логические группы узлов (topo_group) +Один сервис может работать с несколькими группами. +Группа - это изолированый от других групп список узлов, между которыми выстраивается диначическая маршрутизация и могут быть реализованы дополнительные сервисы. +Для чата сейчас реализовано: +- суперузлы - узлы, обеспечивающие связь между узлами и главные хранители индексов медиафайлов +- медиа-узлы - узлы, хранящие медиафайлы (помимо клиентов у которых есть загруенные копии файлов) + +3. Автоматический роутинг. +При подключении узла к узлу группы активируется BGP-подобный роутинг. +Тоесть узлы обмениваются информацией о соседях и каждый узел выстраивает карту связности со всеми узлами сети в этой группе через промежуточные узлы. +Эта связь используется только для сигнального трафика, не для передачи медиафайлов. +Если какой-то узел отвалился и в результате появились недоступные узлы то каждый узел который был связан напрямую с отвалившимся, пробует подключить отвалившийся сегмент сети через другие узлы. +Таким образом, если в сети остаётся хотябы один узел с публичными адресами - связь будет восстановлена. + +4. Чат, мемберы и медиахранилище +При создании группы создаётся приватный-публичный ключ группы. Им подписываются действия суперадминов - можно задавать админов группы, суперузлы, медиаузлы итд. +Для каждой группы есть в базе: +- список мемберов (участников группы, со своими публичными ключами и адресами-кандидатами для подклчюения), и их тип который может задавать владелец группы (все сообщения подписываются) + мемберы синхронизируются между узлами используя merkle-tree сравнение таблиц. +- список сообщений чата. append-only таблица. синхронизируется только хвост - находим первое расхождение и добавляем отсутствующие записи соседей. +- список медиаблоков. + Таблица в которой указано какие узлы хранят какие медиафрагменты. + И какие мадиафрагменты скачивают. + Позволяет вытраивать цеочку раздачи контента. например: + - владелец опубликовал медиафайл порезанный на 10 кусков. + Далее 100 человек в чате увидели сообщение и захотели скачать. + Обратились к суперузлам с вопросом - где есть блоки. + Медиаузел отвечает: + - список узлов у кого есть нужный блок или кто уже качает нужный блок. + Далее узел который хочет скачать устанавливает оптимальную связь с узлом (напрямую или через оптимального посредника), после - скачивает нужный блок + Владелец блока имеет лимит количества подклчений. Если лимит исчерпан - он возвращает список ущлв которые скачивают у него контент (Я загружен - качай у следующих по цепочке). + Таким образом узел желающий скачать контент, дойдёт до незагруженного узла через дерево загрузок и начнёт у него скачивать. Точнее, выберет лучшего по RTT и скачает. + Подобным образом можно выстраивать раздачу Live контента, оптимизируя связность узлов по RTT. + + todo: оптимизация цепочки раздачи. + Допустим есть картина: + A (стример) -> B (посредник) -> C (потребитель) + [msk] 50ms [мухосранск] 50ms [msk] + Есть узел D(msk). у которого пинги: A-D: 5ms, B-D 50ms, C-D 5ms. + Тоесть узел заменив B может время цепочки A-C снизить с 100ms до 10ms. + Он предлагает после замеров RTT свою кандидатуру. наступает фаза probe - сравниваем несколько секунд RTT и объем у старого и нового узла. И принимаем решение - замена или отмена. diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 9e299590..50dbd667 100644 --- a/src/chat/chat_event.h +++ b/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_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_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); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index dad125f4..f3d27950 100644 --- a/src/chat/chat_setting.c +++ b/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_rise_rate", CHAT_SETTING_INT, 10, 1, 100}, {"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])) diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index ad72bf73..2ad30049 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -1,5 +1,6 @@ #include "chat_sync.h" #include "chat_core.h" +#include "chat_setting.h" #include "chat_event.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" @@ -50,6 +51,11 @@ struct chat_sync { uint8_t sync_scheduled; uint64_t pending_invite_ch_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" @@ -72,6 +78,7 @@ static const char* cs_msg_name(uint8_t type) { case CS_MSG_PEER_UPSERT: return "PEER_UPSERT"; case CS_MSG_ERROR: return "ERROR"; case CS_MSG_PEER_REMOVE: return "PEER_REMOVE"; + case CS_MSG_CHANNEL_INVITE: return "CHANNEL_INVITE"; 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); static void cs_handle_peer_remove(struct chat_sync* cs, uint64_t peer, 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 ── */ @@ -178,6 +187,7 @@ static void chat_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { 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; } 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; char ch_id_str[64]; 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 }; - cs_send(cs, ch_id_str, peer, req, 1); + size_t plen = strlen(cs->pending_password); + 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_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", - CS_ID, (unsigned long long)cs->pending_invite_ch_id, (unsigned long long)peer); + 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, plen); } /* ── 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, const uint8_t* pubkey_bin, 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) { int r = -7; #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_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__ - __android_log_print(ANDROID_LOG_INFO, "utun-chat", "invite ch=%llu node=%016llx addrs=%d", - (long long)channel_id, (long long)node_id, addr_count); + __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, password ? "yes" : "no"); #endif #ifndef __ANDROID__ - DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite start ch=%llu node=0x%016llx pubkey=%016llx... addrs=%d", - CS_ID, channel_id, node_id, *(const uint64_t*)pubkey_bin, addr_count); + 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, password && password[0] ? "yes" : "no"); #endif 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, 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", CS_ID, ch_id, (unsigned long long)peer); + DEBUG_TRACE(DEBUG_CATEGORY_DB_SYNC, "%s: INFO_REQ ch=%s from=%016llx len=%zu", CS_ID, ch_id, (unsigned long long)peer, len); + + /* 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]; if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, 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", 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 */ +} diff --git a/src/chat/chat_sync.h b/src/chat/chat_sync.h index 3b620d1c..70c21f8f 100644 --- a/src/chat/chat_sync.h +++ b/src/chat/chat_sync.h @@ -53,6 +53,7 @@ struct UASYNC; #define CS_MSG_WELCOME 0x0C #define CS_MSG_PEER_UPSERT 0x0D #define CS_MSG_PEER_REMOVE 0x0E +#define CS_MSG_CHANNEL_INVITE 0x0F /* push-invite: inviter → joiner */ /* Protocol constants */ #define CS_SEND_DATA_MAX 32 @@ -72,11 +73,23 @@ void chat_sync_destroy(struct UTUN_INSTANCE* inst); /* Прямое ETCP-подключение к узлу (pubkey+адреса из БД) */ 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, const uint8_t* pubkey_bin, 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-поток). Соединение с target_node_id должно быть установлено (links_up && initialized). diff --git a/src/config_parser.c b/src/config_parser.c index 452dc18b..2da949aa 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -52,6 +52,7 @@ typedef enum { SECTION_GUI, SECTION_CHAT, SECTION_NTP, + SECTION_CHATSERVER, SECTION_LOG_UDP } section_type_t; @@ -718,6 +719,51 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c 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) { if (strcmp(key, "id") == 0) { 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, "gui") == 0) return SECTION_GUI; 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; 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_server_count = 0; 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; 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 уже напечатал ошибку */ } 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: break; 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_server_count = 0; 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; 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_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_CHATSERVER: cfg->global.chatserver_enabled = 1; parse_chatserver(key, value, &cfg->global, filename, line_num); break; case SECTION_GUI: break; case SECTION_LOG_UDP: 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) { // Minimal implementation: just append to file FILE *fp = fopen(filename, "a"); diff --git a/src/config_parser.h b/src/config_parser.h index 52a747b6..8efe9f20 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -194,6 +194,13 @@ struct global_config { struct CFG_NTP_SERVER *ntp_servers; // linked list, unlimited int ntp_server_count; 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 { @@ -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); void free_config(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); #ifdef __cplusplus diff --git a/src/utun.c b/src/utun.c index e535b429..30c1b92c 100644 --- a/src/utun.c +++ b/src/utun.c @@ -348,6 +348,7 @@ int main(int argc, char *argv[]) { // Print config for debugging if (args.foreground) { print_config(instance->config); + print_chatserver_invite_link(instance->config, instance->config->servers); } // Initialize all components (TUN, routing, connections) diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index d14ea1ba..cb267a95 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("com.google.code.gson:gson:2.10.1") 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-lifecycle:1.4.0") implementation("androidx.camera:camera-view:1.4.0") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 130382f9..dbbb8892 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/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.lifecycle.viewmodel.compose.viewModel import com.utun.chat.data.InviteData +import com.utun.chat.data.LogManager import com.utun.chat.data.NativeLib import com.utun.chat.headless.HeadlessService import com.utun.chat.ui.screens.* @@ -30,6 +31,7 @@ class MainActivity : ComponentActivity() { var qrResult by remember { mutableStateOf(null) } var showJoinDialog by remember { mutableStateOf(false) } var joinAutoLink by remember { mutableStateOf(null) } + var inviteLinkText by remember { mutableStateOf(null) } BackHandler(enabled = screen != Screen.ChannelList) { when (screen) { @@ -57,6 +59,16 @@ class MainActivity : ComponentActivity() { onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onCreateChannel = { vm.createChannel("general", "channel_1") }, 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 }, onLogs = { screen = Screen.Logs }, onRefresh = { vm.requestRefresh() } @@ -128,6 +140,10 @@ class MainActivity : ComponentActivity() { onDismiss = { showJoinDialog = false } ) } + + inviteLinkText?.let { link -> + InviteLinkDialog(link = link, onDismiss = { inviteLinkText = null }) + } } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 0040f0af..0514bc54 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -48,6 +48,8 @@ object NativeLib { fun getMyNodeId(): Long = nativeGetMyNodeId() + fun encodeInviteLink(channelId: Long): String? = nativeEncodeInviteLink(channelId) + fun ping() { nativePing() } fun isResponsive(): Boolean = nativeIsResponsive() @@ -130,6 +132,7 @@ object NativeLib { private external fun nativeRegenerateKeys() private external fun nativeClearDatabase(): Boolean private external fun nativeGetMyNodeId(): Long + private external fun nativeEncodeInviteLink(channelId: Long): String? private external fun nativeRestart(configText: String): Boolean private external fun nativePing() private external fun nativeIsResponsive(): Boolean diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 50b54ce9..f67879da 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -33,6 +33,7 @@ fun ChannelListScreen( onSelectChannel: (Channel) -> Unit, onCreateChannel: () -> Unit, onJoinChannel: () -> Unit, + onInviteClick: (Channel) -> Unit, onSettings: () -> Unit, onLogs: () -> Unit, onRefresh: () -> Unit = {} @@ -87,7 +88,8 @@ fun ChannelListScreen( Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Row(Modifier.fillMaxWidth().padding(12.dp)) { 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)) } if (badgeText.isNotEmpty()) { diff --git a/tools/chatgui-android/headless/headless_control.c b/tools/chatgui-android/headless/headless_control.c index 9633cc79..0e6c17e6 100644 --- a/tools/chatgui-android/headless/headless_control.c +++ b/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..."); - 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]; snprintf(resp, sizeof(resp), diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index c08f7273..5ec2a577 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/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() */ #include "chat/chat_sync.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, 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 */ 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) { @@ -243,8 +443,6 @@ void utun_bridge_connect_channel(const char* channel_id) { uasync_post(ua, chat_core_connect_channel_trampoline, cid); } -#include "chat/chat_core_priv.h" - static char* json_escape_alloc(const char* str) { if (!str) return u_strdup(""); 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; } +/* ── 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) ── */ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index b58b0031..3e37c856 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/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); 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 ── */ void utun_bridge_set_debug_level(const char* category, const char* level); diff --git a/tools/chatgui-android/libutun_lite/invite_link_c.c b/tools/chatgui-android/libutun_lite/invite_link_c.c index 8ded8d07..0f9f6870 100644 --- a/tools/chatgui-android/libutun_lite/invite_link_c.c +++ b/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; - if (raw[off++] != INVITE_LINK_VERSION) { - if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "unsupported version 0x%02x", raw[off - 1]); + uint8_t ver = raw[off++]; + 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; } + 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 (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at channel_id"); return -1; @@ -145,13 +166,20 @@ int invite_serialize_addrs(const struct InviteDataC* data, uint8_t* buf, size_t 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->addrCount == 0) return -1; + int has_pass = (password && password[0]) ? 1 : 0; uint8_t raw[4096]; 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); uint8_t pk[32]; memcpy(pk, data->pubkey, 32); diff --git a/tools/chatgui-android/libutun_lite/invite_link_c.h b/tools/chatgui-android/libutun_lite/invite_link_c.h index 9a7dd6d7..07616381 100644 --- a/tools/chatgui-android/libutun_lite/invite_link_c.h +++ b/tools/chatgui-android/libutun_lite/invite_link_c.h @@ -7,6 +7,7 @@ #define INVITE_LINK_VERSION 0x01 #define INVITE_PUBKEY_SIZE 32 #define INVITE_ADDR_MAX 32 +#define INVITE_PASS_MAX 128 #define INVITE_PROTO_UDP 0x01 #define INVITE_PROTO_TCP 0x02 @@ -30,6 +31,8 @@ struct InviteDataC { uint64_t nodeId; uint8_t addrCount; 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. @@ -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); /* 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 */ -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(): family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) diff --git a/tools/chatgui/src/invite_link.cpp b/tools/chatgui/src/invite_link.cpp index 2f8beb22..f56bbc6c 100644 --- a/tools/chatgui/src/invite_link.cpp +++ b/tools/chatgui/src/invite_link.cpp @@ -3,7 +3,8 @@ #include QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, - const QList& addrs) { + const QList& addrs, + const QString& password) { if (pubkey.size() != 32) { GUI_ERROR("encodeInviteLink: pubkey must be 32 bytes (got %d)", pubkey.size()); return {}; @@ -14,9 +15,16 @@ QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, } 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(INVITE_LINK_VERSION)); + blob.append(static_cast(hasPass ? 0x02 : INVITE_LINK_VERSION)); + + if (hasPass) { + QByteArray passBytes = password.toUtf8(); + blob.append(static_cast(passBytes.size())); + blob.append(passBytes); + } /* channel_id: uint64 big-endian */ for (int i = 7; i >= 0; i--) @@ -73,12 +81,30 @@ InviteData decodeInviteLink(const QString& link) { } int off = 0; - if (data[off++] != INVITE_LINK_VERSION) { - d.error = QStringLiteral("unsupported version 0x%1").arg(data[off - 1], 2, 16, QLatin1Char('0')); - GUI_ERROR("decodeInviteLink: unsupported version %d", data[off - 1]); + uint8_t ver = data[off++]; + if (ver != INVITE_LINK_VERSION && ver != 0x02) { + 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; + } + 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(data + off), plen); + off += plen; + } + } + /* channel_id: uint64 big-endian */ if (off + 8 > len) { d.error = QStringLiteral("truncated at channel_id"); diff --git a/tools/chatgui/src/invite_link.h b/tools/chatgui/src/invite_link.h index 335edced..ea1c7b8c 100644 --- a/tools/chatgui/src/invite_link.h +++ b/tools/chatgui/src/invite_link.h @@ -24,10 +24,12 @@ struct InviteData { uint64_t channelId = 0; QByteArray pubkey; QList addrs; + QString password; QString error; }; QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, - const QList& addrs); + const QList& addrs, + const QString& password = QString()); InviteData decodeInviteLink(const QString& link); diff --git a/tools/chatgui/src/invitedialog.cpp b/tools/chatgui/src/invitedialog.cpp index 3c7372b9..a4bdef9a 100644 --- a/tools/chatgui/src/invitedialog.cpp +++ b/tools/chatgui/src/invitedialog.cpp @@ -1,5 +1,14 @@ #include "invitedialog.h" +#include "invite_link.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 #include #include @@ -8,9 +17,11 @@ #include #include #include +#include -InviteDialog::InviteDialog(const QString& link, QWidget* parent) - : QDialog(parent), m_link(link) +InviteDialog::InviteDialog(const QString& link, const QString& channelId, + UtunNode* node, QWidget* parent) + : QDialog(parent), m_link(link), m_channelId(channelId), m_node(node) { setWindowTitle("Пригласительная ссылка"); setMinimumWidth(420); @@ -25,7 +36,7 @@ InviteDialog::InviteDialog(const QString& link, QWidget* parent) layout->addWidget(linkEdit); auto *btnLayout = new QHBoxLayout(); - auto *copyBtn = new QPushButton("Скопировать", this); + auto *copyBtn = new QPushButton("Копировать", this); connect(copyBtn, &QPushButton::clicked, this, [this, linkEdit]() { QClipboard* cb = QApplication::clipboard(); cb->clear(QClipboard::Clipboard); @@ -48,6 +59,44 @@ InviteDialog::InviteDialog(const QString& link, QWidget* parent) qrLabel->setAlignment(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); connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept); 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(a.family)); + addrsBuf.append(static_cast(a.socketId)); + addrsBuf.append(static_cast(a.proto)); + addrsBuf.append(a.address); + addrsBuf.append(static_cast((a.port >> 8) & 0xFF)); + addrsBuf.append(static_cast(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); + }); +} diff --git a/tools/chatgui/src/invitedialog.h b/tools/chatgui/src/invitedialog.h index 24ee33e4..33135f80 100644 --- a/tools/chatgui/src/invitedialog.h +++ b/tools/chatgui/src/invitedialog.h @@ -2,12 +2,33 @@ #include #include +#include +#include +#include +#include + +class UtunNode; class InviteDialog : public QDialog { Q_OBJECT 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: - QString m_link; + void setInviteStatus(const QString& text, bool isError = false); + + 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; }; diff --git a/tools/chatgui/src/joindialog.cpp b/tools/chatgui/src/joindialog.cpp index 75c3bce8..445b4a68 100644 --- a/tools/chatgui/src/joindialog.cpp +++ b/tools/chatgui/src/joindialog.cpp @@ -185,10 +185,11 @@ void JoinDialog::onConnectClicked() { } int addrCount = d.addrs.size(); + const char* pass = d.password.isEmpty() ? NULL : d.password.toUtf8().constData(); chat_sync_connect_from_invite(d.channelId, nodeId, (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) { diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 546a6181..ad8c2930 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -364,7 +364,7 @@ void MainWindow::showInviteDialog(const QString& channelId) { return; } - InviteDialog dlg(link, this); + InviteDialog dlg(link, channelId, m_node, this); dlg.exec(); } diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 31f43fd5..b86be409 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/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;" " padding: 6px 10px; font-size: 13px; background: palette(base); }"); 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); // Storage page @@ -151,6 +161,8 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod if (db) { int sortMode = db->getUiStateInt("channel_sort", 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_soundPage->loadFromDb(); @@ -237,5 +249,22 @@ void SettingsDialog::onSave() { m_soundPage->applyAndSave(); m_audioDevicePage->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(); } diff --git a/tools/chatgui/src/settingsdialog.h b/tools/chatgui/src/settingsdialog.h index 38935226..86e33938 100644 --- a/tools/chatgui/src/settingsdialog.h +++ b/tools/chatgui/src/settingsdialog.h @@ -39,6 +39,7 @@ private: QPushButton* m_saveBtn; QLineEdit* m_nicknameEdit; QComboBox* m_sortCombo; + QComboBox* m_joinPolicyCombo; QString m_oldNickname; NetworkSettingsPage* m_networkPage; DatabaseSettingsPage* m_databasePage; diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 4b46a4b5..8d8d6c31 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/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); 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 */ void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index ed539a52..f6ca0b1e 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/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_download_progress_fn g_download_progress_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; /* ── GuiBridgeReceiver implementation ── */ @@ -170,6 +171,9 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { case GUI_EVT_NODEINFO_UPDATE: if (g_nodeinfo_update_cb) g_nodeinfo_update_cb(d, dlen); break; + case 18: /* CHAT_EVT_INVITE_RECEIVED */ + if (g_invite_received_cb) g_invite_received_cb(d, dlen); + break; default: DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType); break; @@ -264,6 +268,10 @@ void gui_bridge_set_nodeinfo_update_cb(gui_nodeinfo_update_fn 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" */ /* ── uTun nodeinfo callback (called from uasync thread) ── */ diff --git a/utun.conf.sample b/utun.conf.sample index b47fa222..77bca423 100644 --- a/utun.conf.sample +++ b/utun.conf.sample @@ -107,3 +107,10 @@ allow=all # Принимает CONNECT-запросы от других нод и открывает OS сокеты к адресатам. #[tcp_proxy_server] #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=отключено)