792 lines
35 KiB
C
792 lines
35 KiB
C
// test_radio.c — интеграционный тест рации (PTT мультикаст голоса).
|
|
//
|
|
// Однопоточный: 3 узла (A, B, C) в одном процессе, один UASYNC, без fork.
|
|
// Топология — цепочка A←B←C (A сервер, B клиент к A + сервер, C клиент к B).
|
|
// Все три в одной CHAT-группе. Master state-machine на таймере.
|
|
//
|
|
// Сценарий:
|
|
// 1. A,B,C radio_on → флаг TOPO_FLAG_RADIO распространяется по BGP (node.radio==1).
|
|
// 2. A: radio_talk_begin + 5 кадров + radio_talk_end → B и C (через relay B) получают
|
|
// кадры в порядке seq 0..4 и FIN.
|
|
// 3. C radio_off (обрезание) → A снова говорит → B получает, C не получает.
|
|
// 4. Без FIN: C снимает индикацию при radio_off, B — по таймауту источника.
|
|
|
|
#include "chat_core.h"
|
|
#include "chat_sync.h"
|
|
#include "chat_event.h"
|
|
#include "chat_member.h"
|
|
#include "member_sync.h"
|
|
#include "../radio/radio.h"
|
|
#include "../radio/radio_call.h"
|
|
#include "../routing_layer/etcp_router.h"
|
|
#include "../routing_layer/topo_node_sqlite.h"
|
|
#include "../routing_layer/topo_group.h"
|
|
#include "../routing_layer/topo_node.h"
|
|
#include "../utun_instance.h"
|
|
#include "../transport_layer/etcp.h"
|
|
#include "../transport_layer/secure_channel.h"
|
|
#include "../ntp_time.h"
|
|
#include "../lib/u_async.h"
|
|
#include "../lib/debug_config.h"
|
|
#include "../lib/mem.h"
|
|
#include "../lib/platform_compat.h"
|
|
#include "test_utils.h"
|
|
#include "test_clock.h"
|
|
#include "test_net.h"
|
|
|
|
#include <sqlite3.h>
|
|
#define OPENSSL_API_COMPAT 0x10100000L
|
|
#include <openssl/evp.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdarg.h>
|
|
#include <time.h>
|
|
|
|
#define CH_ID "4242424242424242"
|
|
|
|
#define IDX_A 0
|
|
#define IDX_B 1
|
|
#define IDX_C 2
|
|
|
|
#define TICK_TB 100 /* 10 ms на такт */
|
|
#define MAX_TICKS 12000 /* включает минуту ожидания решения по вызову */
|
|
#define N_FRAMES 5
|
|
|
|
enum rad_phase {
|
|
P_WAIT_CONN,
|
|
P_SETUP,
|
|
P_WAIT_BGP,
|
|
P_RADIO_CALLS,
|
|
P_RADIO_ON,
|
|
P_WAIT_FLAGS,
|
|
P_TALK1,
|
|
P_WAIT_RX1,
|
|
P_WAIT_MUTED_RELAY,
|
|
P_RADIO_OFF_C,
|
|
P_WAIT_OFF_C,
|
|
P_TALK2,
|
|
P_WAIT_RX2,
|
|
P_REENABLE_C,
|
|
P_WAIT_REENABLE_C,
|
|
P_TALK_STALLED,
|
|
P_WAIT_STALLED_START,
|
|
P_WAIT_STALLED_STOP,
|
|
P_WAIT_RESTORED_C,
|
|
P_WITHDRAW_C,
|
|
P_DONE,
|
|
};
|
|
|
|
struct rad_ctx {
|
|
struct UASYNC* ua;
|
|
struct UTUN_INSTANCE* inst[3];
|
|
enum rad_phase phase;
|
|
int result; /* 0=running, 1=ok, 2=fail */
|
|
int ticks;
|
|
void* timer;
|
|
int call_step;
|
|
uint64_t call_started;
|
|
};
|
|
|
|
/* приёмник кадров (per-узел): проверка порядка seq и FIN */
|
|
struct rx_evt {
|
|
int active;
|
|
uint16_t stream;
|
|
uint16_t expect_seq;
|
|
int frames; /* число data-кадров (fin=0) текущего потока */
|
|
int fin_seen;
|
|
int seq_ok;
|
|
};
|
|
|
|
struct rad_shared {
|
|
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64];
|
|
uint64_t nid[3];
|
|
uint8_t x_pub[3][32], x_priv[3][32], ed_pub[3][32];
|
|
};
|
|
static struct rad_shared g_sh;
|
|
static struct rx_evt g_rx[3];
|
|
static int g_member_radio[3][3];
|
|
static int g_member_lists[3];
|
|
static int g_member_event_bad;
|
|
static int g_talk_on[3], g_talk_starts[3], g_talk_stops[3];
|
|
static struct { int state, reason, finished, incoming; uint64_t finished_tb; } g_calls[6][3];
|
|
static int g_call_ready, g_drop_result, g_drop_ack;
|
|
static etcp_recv_fn g_call_recv[3];
|
|
|
|
/* Потеря приложения после router ACK: восстановить результат обязан сервис, не транспорт. */
|
|
static void call_packet_loss(struct ETCP_CONN* conn, struct ll_entry* entry) {
|
|
int idx = -1;
|
|
for (int i = 0; i < 3; ++i) if (conn->instance->node_id == g_sh.nid[i]) idx = i;
|
|
if (idx < 0 || !entry) abort();
|
|
if (entry->len <= ROUTER_SVC_PAYLOAD_OFF) { g_call_recv[idx](conn, entry); return; }
|
|
int cmd = entry->dgram[ROUTER_SVC_PAYLOAD_OFF];
|
|
if ((idx == IDX_A && cmd == 3 && g_drop_result-- > 0) || (idx == IDX_C && cmd == 4 && g_drop_ack-- > 0)) {
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test drops radio result/ACK dst=%d command=%d", idx, cmd);
|
|
queue_dgram_free(entry); queue_entry_free(entry); return;
|
|
}
|
|
g_call_recv[idx](conn, entry);
|
|
}
|
|
|
|
static void radio_action(struct UTUN_INSTANCE* inst, uint64_t id, int action, uint64_t peer) {
|
|
struct radio_call_request req = {inst, id, strtoull(CH_ID, NULL, 10), peer, action};
|
|
radio_call_execute(&req);
|
|
}
|
|
|
|
/* Проверяем именно UI-события: self, прямой пир и пир за посредником. */
|
|
static void member_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) {
|
|
if (type == CHAT_EVT_RADIO_CALL) {
|
|
uint64_t id, group, peer;
|
|
if (!data || len != 27) { g_member_event_bad = 1; return; }
|
|
memcpy(&id, data, 8); memcpy(&group, data + 8, 8); memcpy(&peer, data + 16, 8);
|
|
int idx = -1;
|
|
for (int i = 0; i < 3; ++i) if (inst->node_id == g_sh.nid[i]) idx = i;
|
|
if (idx < 0 || id < 100 || id > 105 || group != strtoull(CH_ID, NULL, 10)) { g_member_event_bad = 1; return; }
|
|
g_calls[id - 100][idx].state = data[24]; g_calls[id - 100][idx].reason = data[25];
|
|
if (data[24] == RADIO_CALL_INCOMING) {
|
|
++g_calls[id - 100][idx].incoming;
|
|
if (g_call_ready) radio_action(inst, id, RADIO_CALL_READY, 0);
|
|
}
|
|
if (data[24] == RADIO_CALL_FINISHED) {
|
|
++g_calls[id - 100][idx].finished; g_calls[id - 100][idx].finished_tb = get_time_tb();
|
|
if (data[25] == RADIO_CALL_ACCEPTED) radio_set_active(inst, group, 1);
|
|
}
|
|
return;
|
|
}
|
|
if (type == CHAT_EVT_RADIO_TALK) {
|
|
uint64_t group, source;
|
|
if (!data || len != 19 || data[18] > 1) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: invalid talk event bytes=%d", len); g_member_event_bad = 1; return;
|
|
}
|
|
memcpy(&group, data, 8); memcpy(&source, data + 8, 8);
|
|
int dst = -1;
|
|
for (int i = 0; i < 3; ++i) if (inst->node_id == g_sh.nid[i]) dst = i;
|
|
if (dst < 0 || group != strtoull(CH_ID, NULL, 10) || source != g_sh.nid[IDX_A]) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: unexpected talk dst=%d group=%016llx source=%016llx",
|
|
dst, (unsigned long long)group, (unsigned long long)source);
|
|
g_member_event_bad = 1; return;
|
|
}
|
|
g_talk_on[dst] = data[18];
|
|
if (data[18]) ++g_talk_starts[dst];
|
|
else ++g_talk_stops[dst];
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "test: talk event dst=%d on=%d starts=%d stops=%d",
|
|
dst, data[18], g_talk_starts[dst], g_talk_stops[dst]);
|
|
return;
|
|
}
|
|
if (type != CHAT_EVT_MEMBER_UPDATED && type != CHAT_EVT_MEMBER_LIST) return;
|
|
if (!data || len < 3) { g_member_event_bad = 1; return; }
|
|
int cl = data[0];
|
|
if (cl != (int)strlen(CH_ID) || len < cl + 3 || memcmp(data + 1, CH_ID, cl)) return;
|
|
int count = data[cl + 1];
|
|
int off = cl + 2;
|
|
if (type == CHAT_EVT_MEMBER_LIST) { count |= data[cl + 2] << 8; off++; }
|
|
if (len != off + count * CHAT_MEMBER_DISPLAY_SIZE) { g_member_event_bad = 1; return; }
|
|
int dst = -1;
|
|
for (int i = 0; i < 3; i++) if (inst->node_id == g_sh.nid[i]) dst = i;
|
|
if (dst < 0) { g_member_event_bad = 1; return; }
|
|
if (type == CHAT_EVT_MEMBER_LIST) g_member_lists[dst]++;
|
|
for (int i = 0; i < count; i++) {
|
|
struct chat_member_display member;
|
|
if (chat_core_deserialize_member(data + off + i * CHAT_MEMBER_DISPLAY_SIZE, &member) < 0) {
|
|
g_member_event_bad = 1; return;
|
|
}
|
|
for (int j = 0; j < 3; j++)
|
|
if (member.node_id == g_sh.nid[j]) g_member_radio[dst][j] = !!(member.flags & CHAT_MEMBER_FLAG_RADIO);
|
|
}
|
|
}
|
|
|
|
/* Полный список, одиночная строка и текущие события должны совпадать. */
|
|
static int check_member_radios(struct UTUN_INSTANCE* inst, int dst, int c_radio) {
|
|
uint8_t* wire = NULL; int count = 0;
|
|
if (chat_core_get_member_list(inst, CH_ID, &wire, &count) < 0 || count != 3 || g_member_event_bad) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: invalid member list dst=%d count=%d event_bad=%d", dst, count, g_member_event_bad);
|
|
u_free(wire); return 0;
|
|
}
|
|
int seen = 0;
|
|
for (int i = 0; i < count; i++) {
|
|
struct chat_member_display member;
|
|
chat_core_deserialize_member(wire + i * CHAT_MEMBER_DISPLAY_SIZE, &member);
|
|
for (int j = 0; j < 3; j++) {
|
|
if (member.node_id != g_sh.nid[j]) continue;
|
|
uint8_t single[CHAT_MEMBER_DISPLAY_SIZE];
|
|
int expected = j == IDX_C ? c_radio : 1;
|
|
int radio = !!(member.flags & CHAT_MEMBER_FLAG_RADIO);
|
|
if (radio != expected || g_member_radio[dst][j] != expected
|
|
|| chat_core_get_single_member(inst, CH_ID, member.node_id, single) < 0
|
|
|| !!(single[10] & CHAT_MEMBER_FLAG_RADIO) != expected) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: member radio dst=%d src=%d list=%d event=%d expected=%d",
|
|
dst, j, radio, g_member_radio[dst][j], expected);
|
|
u_free(wire); return 0;
|
|
}
|
|
seen |= 1 << j;
|
|
}
|
|
}
|
|
u_free(wire);
|
|
return seen == 7;
|
|
}
|
|
|
|
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0;
|
|
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0)
|
|
#define OK() do { G_PASSED++; printf("OK\n"); } while(0)
|
|
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0)
|
|
|
|
/* ── крипто-хелперы ── */
|
|
|
|
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) {
|
|
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
|
|
EVP_PKEY* pkey = NULL;
|
|
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
|
|
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
|
|
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
|
|
EVP_PKEY_free(pkey);
|
|
}
|
|
|
|
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) {
|
|
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
|
|
EVP_PKEY* pkey = NULL;
|
|
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx);
|
|
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l);
|
|
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l);
|
|
EVP_PKEY_free(pkey);
|
|
}
|
|
|
|
static void to_hex(const uint8_t* bin, size_t n, char* out) {
|
|
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]);
|
|
out[n * 2] = '\0';
|
|
}
|
|
|
|
static int wf(const char* p, const char* f, ...) {
|
|
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1;
|
|
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0;
|
|
}
|
|
|
|
/* ── данные сценария + конфиги (цепочка A←B←C) ── */
|
|
|
|
static void fill_shared(void) {
|
|
memset(&g_sh, 0, sizeof(g_sh));
|
|
for (int i = 0; i < 3; i++) {
|
|
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]);
|
|
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]);
|
|
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]);
|
|
}
|
|
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); }
|
|
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv);
|
|
{
|
|
uint8_t msg[256]; size_t off = 0;
|
|
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1;
|
|
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "radio-test") + 1;
|
|
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8;
|
|
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32;
|
|
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32;
|
|
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig);
|
|
}
|
|
}
|
|
|
|
static void write_configs(const char* dir) {
|
|
for (int i = 0; i < 3; i++) {
|
|
char priv[65], pub[65], path[512], dbd[512];
|
|
to_hex(g_sh.x_priv[i], 32, priv);
|
|
to_hex(g_sh.x_pub[i], 32, pub);
|
|
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c");
|
|
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c");
|
|
utun_mkdir(dbd, 0755);
|
|
|
|
char client[512] = "";
|
|
if (i == IDX_B) { /* B → A */
|
|
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub);
|
|
snprintf(client, sizeof(client), "[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:0\n", apub);
|
|
} else if (i == IDX_C) { /* C → B */
|
|
char bpub[65]; to_hex(g_sh.x_pub[IDX_B], 32, bpub);
|
|
snprintf(client, sizeof(client), "[client: to_b]\npeer_public_key=%s\nlink=s1:127.0.0.1:0\n", bpub);
|
|
}
|
|
|
|
wf(path,
|
|
"[global]\ntun_ip=10.98.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n"
|
|
"my_private_key=%s\nmy_public_key=%s\n"
|
|
"[server: s1]\naddr=127.0.0.1:0\ntype=public\n"
|
|
"%s"
|
|
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n",
|
|
i, i, dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c",
|
|
priv, pub, client);
|
|
}
|
|
}
|
|
|
|
/* ── проверки ── */
|
|
|
|
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) {
|
|
struct ETCP_CONN* c = instance_find_conn(inst, nid);
|
|
return c && c->initialized && c->links_up;
|
|
}
|
|
|
|
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) {
|
|
uint64_t gid = strtoull(CH_ID, NULL, 10);
|
|
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
|
|
if (!g) return 0;
|
|
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
|
|
return nq && nq->paths && nq->paths->head;
|
|
}
|
|
|
|
static int node_radio(struct UTUN_INSTANCE* inst, uint64_t nid) {
|
|
uint64_t gid = strtoull(CH_ID, NULL, 10);
|
|
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid);
|
|
if (!g) return 0;
|
|
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid);
|
|
return nq ? nq->radio : 0;
|
|
}
|
|
|
|
/* ── frame cb (per-узел) ── */
|
|
|
|
static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id,
|
|
uint64_t src, uint16_t stream, uint16_t seq,
|
|
uint8_t fin, const uint8_t* opus, int len, void* arg) {
|
|
(void)opus; (void)len;
|
|
if (radio_member_muted(inst, group_id, src)) return; /* локальный аудио-потребитель */
|
|
struct rx_evt* rx = (struct rx_evt*)arg;
|
|
if (fin) {
|
|
if (rx->active && stream == rx->stream) rx->fin_seen = 1;
|
|
return;
|
|
}
|
|
if (!rx->active || stream != rx->stream) {
|
|
rx->active = 1;
|
|
rx->stream = stream;
|
|
rx->expect_seq = 0;
|
|
rx->frames = 0;
|
|
rx->fin_seen = 0;
|
|
rx->seq_ok = 1;
|
|
}
|
|
if (seq != rx->expect_seq) rx->seq_ok = 0;
|
|
rx->expect_seq = (uint16_t)(seq + 1);
|
|
rx->frames++;
|
|
}
|
|
|
|
/* ── настройка канала и мемберов ── */
|
|
|
|
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) {
|
|
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "radio-test", g_sh.nid[IDX_A],
|
|
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig);
|
|
chat_core_ensure_channel_ready(inst, CH_ID);
|
|
}
|
|
|
|
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) {
|
|
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst);
|
|
uint8_t jm[128];
|
|
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id,
|
|
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm));
|
|
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig);
|
|
uint8_t tree_sig[64]; member_sync_sign_pubkey(g_sh.ch_ed_priv, inst->my_keys.public_key, tree_sig);
|
|
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
|
|
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey,
|
|
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, tree_sig);
|
|
}
|
|
|
|
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) {
|
|
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name);
|
|
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx],
|
|
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL);
|
|
}
|
|
|
|
static void do_setup(struct UTUN_INSTANCE* inst, int role) {
|
|
switch (role) {
|
|
case IDX_A:
|
|
channel_put_shared(inst, 1);
|
|
member_put_self(inst, "A");
|
|
member_put_placeholder(inst, IDX_B, "B");
|
|
member_put_placeholder(inst, IDX_C, "C");
|
|
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
|
|
break;
|
|
case IDX_B:
|
|
channel_put_shared(inst, 0);
|
|
member_put_self(inst, "B");
|
|
member_put_placeholder(inst, IDX_A, "A");
|
|
member_put_placeholder(inst, IDX_C, "C");
|
|
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL);
|
|
member_sync_start(inst, g_sh.nid[IDX_C], CH_ID, NULL, NULL);
|
|
break;
|
|
case IDX_C:
|
|
channel_put_shared(inst, 0);
|
|
member_put_self(inst, "C");
|
|
member_put_placeholder(inst, IDX_A, "A");
|
|
member_put_placeholder(inst, IDX_B, "B");
|
|
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ── talk: 5 кадров + FIN от A ── */
|
|
|
|
static void do_talk(struct UTUN_INSTANCE* A) {
|
|
uint64_t gid = strtoull(CH_ID, NULL, 10);
|
|
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d };
|
|
radio_talk_begin(A, gid);
|
|
for (int i = 0; i < N_FRAMES; i++) radio_talk_send(A, gid, opus, sizeof(opus));
|
|
radio_talk_end(A, gid);
|
|
}
|
|
|
|
/* ── master state-machine ── */
|
|
|
|
static void rad_tick(void* arg) {
|
|
struct rad_ctx* t = arg;
|
|
t->timer = NULL;
|
|
if (t->result) return;
|
|
|
|
struct UTUN_INSTANCE* A = t->inst[IDX_A];
|
|
struct UTUN_INSTANCE* B = t->inst[IDX_B];
|
|
struct UTUN_INSTANCE* C = t->inst[IDX_C];
|
|
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B], nc = g_sh.nid[IDX_C];
|
|
uint64_t gid = strtoull(CH_ID, NULL, 10);
|
|
|
|
switch (t->phase) {
|
|
case P_WAIT_CONN:
|
|
if (conn_up(A, nb) && conn_up(B, na) && conn_up(B, nc) && conn_up(C, nb))
|
|
t->phase = P_SETUP;
|
|
break;
|
|
case P_SETUP:
|
|
do_setup(A, IDX_A);
|
|
do_setup(B, IDX_B);
|
|
do_setup(C, IDX_C);
|
|
t->phase = P_WAIT_BGP;
|
|
break;
|
|
case P_WAIT_BGP:
|
|
if (bgp_has(A, nb) && bgp_has(A, nc) && bgp_has(B, na) && bgp_has(B, nc) && bgp_has(C, na) && bgp_has(C, nb))
|
|
t->phase = P_RADIO_CALLS;
|
|
break;
|
|
case P_RADIO_CALLS: {
|
|
uint64_t now = get_time_tb();
|
|
if (g_member_event_bad) { t->result = 2; uasync_stop(t->ua); return; }
|
|
switch (t->call_step) {
|
|
case 0: {
|
|
uint8_t key[32];
|
|
if (member_sync_verified_ed25519(A, CH_ID, nc, key) || member_sync_verified_ed25519(C, CH_ID, na, key)) break;
|
|
for (int i = 0; i < 3; ++i) {
|
|
g_call_recv[i] = t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_RADIO_CALL];
|
|
t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_RADIO_CALL] = call_packet_loss;
|
|
}
|
|
g_call_ready = 0; t->call_started = now;
|
|
radio_action(A, 100, RADIO_CALL_START, nc); ++t->call_step;
|
|
break;
|
|
}
|
|
case 1:
|
|
if (g_calls[0][IDX_A].finished && g_calls[0][IDX_C].finished) {
|
|
uint64_t elapsed = g_calls[0][IDX_A].finished_tb - t->call_started;
|
|
if (g_calls[0][IDX_A].reason != RADIO_CALL_UNAVAILABLE || elapsed < RADIO_CALL_RESPONSE_TB ||
|
|
elapsed > RADIO_CALL_RESPONSE_TB + 1000 || g_calls[0][IDX_C].incoming != 1) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test first response deadline failed reason=%d elapsed_tb=%llu incoming=%d",
|
|
g_calls[0][IDX_A].reason, (unsigned long long)elapsed, g_calls[0][IDX_C].incoming);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
g_call_ready = 1; g_drop_result = 1; g_drop_ack = 1;
|
|
radio_action(A, 101, RADIO_CALL_START, nc); ++t->call_step;
|
|
}
|
|
break;
|
|
case 2:
|
|
if (g_calls[1][IDX_A].state == RADIO_CALL_RINGING) {
|
|
radio_action(C, 101, RADIO_CALL_ACCEPT, 0); ++t->call_step;
|
|
}
|
|
break;
|
|
case 3:
|
|
if (g_calls[1][IDX_A].finished && g_calls[1][IDX_C].finished) {
|
|
if (g_calls[1][IDX_A].reason != RADIO_CALL_ACCEPTED || !radio_is_active(A, gid) || !radio_is_active(C, gid)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio acceptance failed"); t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
radio_set_active(A, gid, 0); radio_set_active(C, gid, 0);
|
|
radio_action(A, 102, RADIO_CALL_START, nc); ++t->call_step;
|
|
}
|
|
break;
|
|
case 4:
|
|
if (g_calls[2][IDX_A].state == RADIO_CALL_RINGING) { radio_action(C, 102, RADIO_CALL_DECLINE, 0); ++t->call_step; }
|
|
break;
|
|
case 5:
|
|
if (g_calls[2][IDX_A].finished) {
|
|
if (g_calls[2][IDX_A].reason != RADIO_CALL_DECLINED || radio_is_active(A, gid) || radio_is_active(C, gid)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio decline failed"); t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
radio_action(A, 103, RADIO_CALL_START, nc); ++t->call_step;
|
|
}
|
|
break;
|
|
case 6:
|
|
if (g_calls[3][IDX_A].state == RADIO_CALL_RINGING) {
|
|
radio_action(C, 104, RADIO_CALL_START, nb);
|
|
radio_action(A, 103, RADIO_CALL_CANCEL, 0); ++t->call_step;
|
|
}
|
|
break;
|
|
case 7:
|
|
if (g_calls[3][IDX_C].finished && g_calls[4][IDX_C].finished) {
|
|
if (g_calls[3][IDX_C].reason != RADIO_CALL_CANCELLED || g_calls[4][IDX_C].reason != RADIO_CALL_BUSY) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test radio cancel/busy failed"); t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->call_started = now;
|
|
radio_action(A, 105, RADIO_CALL_START, nc); ++t->call_step;
|
|
}
|
|
break;
|
|
case 8:
|
|
if (g_calls[5][IDX_A].finished && g_calls[5][IDX_C].finished) {
|
|
uint64_t elapsed = g_calls[5][IDX_A].finished_tb - t->call_started;
|
|
if (g_calls[5][IDX_A].reason != RADIO_CALL_NO_ANSWER || elapsed < RADIO_CALL_RING_TB || elapsed > RADIO_CALL_RING_TB + 1000) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test one minute deadline failed reason=%d elapsed_tb=%llu",
|
|
g_calls[5][IDX_A].reason, (unsigned long long)elapsed); t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
for (int n = 0; n < 6; ++n) for (int i = 0; i < 3; ++i) {
|
|
if (g_calls[n][i].finished > 1 || g_calls[n][i].incoming > 1) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test duplicated radio call event id=%d node=%d", n + 100, i);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
}
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test radio calls passed: 2s/60s deadlines, accept, decline, cancel, busy, lost result/ACK");
|
|
t->phase = P_RADIO_ON;
|
|
}
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
case P_RADIO_ON:
|
|
radio_set_active(A, gid, 1);
|
|
radio_set_active(B, gid, 1);
|
|
radio_set_active(C, gid, 1);
|
|
t->phase = P_WAIT_FLAGS;
|
|
break;
|
|
case P_WAIT_FLAGS:
|
|
if (node_radio(A, nb) && node_radio(A, nc) && node_radio(B, na) && node_radio(B, nc) && node_radio(C, na) && node_radio(C, nb))
|
|
t->phase = P_TALK1;
|
|
break;
|
|
case P_TALK1:
|
|
for (int i = 0; i < 3; i++) {
|
|
if (!check_member_radios(t->inst[i], i, 1)) { t->result = 2; uasync_stop(t->ua); return; }
|
|
struct chat_member_list_req* req = u_calloc(1, sizeof(*req));
|
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: member request OOM"); t->result = 2; uasync_stop(t->ua); return; }
|
|
req->inst = t->inst[i]; strcpy(req->ch_id, CH_ID);
|
|
chat_core_request_member_list_trampoline(req);
|
|
if (g_member_lists[i] != 1) { t->result = 2; uasync_stop(t->ua); return; }
|
|
}
|
|
memset(g_rx, 0, sizeof(g_rx));
|
|
do_talk(A);
|
|
t->phase = P_WAIT_RX1;
|
|
break;
|
|
case P_WAIT_RX1:
|
|
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok
|
|
&& g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
|
|
if (chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || !radio_member_muted(B, gid, na) ||
|
|
radio_member_muted(C, gid, na)) { t->result = 2; uasync_stop(t->ua); return; }
|
|
memset(g_rx, 0, sizeof(g_rx)); do_talk(A);
|
|
t->phase = P_WAIT_MUTED_RELAY;
|
|
}
|
|
break;
|
|
case P_WAIT_MUTED_RELAY:
|
|
if (g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) {
|
|
if (g_rx[IDX_B].frames || g_rx[IDX_B].fin_seen || !radio_is_active(B, gid) ||
|
|
chat_core_toggle_member_radio_mute(B, CH_ID, na) < 0 || radio_member_muted(B, gid, na)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: locally muted relay blocked downstream or local mute failed");
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->phase = P_RADIO_OFF_C;
|
|
}
|
|
break;
|
|
case P_RADIO_OFF_C:
|
|
radio_set_active(C, gid, 0);
|
|
t->phase = P_WAIT_OFF_C;
|
|
break;
|
|
case P_WAIT_OFF_C:
|
|
/* ждём, пока A и B увидят radio=0 у C (обрезание ветви C) */
|
|
if (!node_radio(A, nc) && !node_radio(B, nc)) {
|
|
memset(g_rx, 0, sizeof(g_rx));
|
|
t->phase = P_TALK2;
|
|
}
|
|
break;
|
|
case P_TALK2:
|
|
for (int i = 0; i < 3; i++)
|
|
if (!check_member_radios(t->inst[i], i, 0)) { t->result = 2; uasync_stop(t->ua); return; }
|
|
do_talk(A);
|
|
t->phase = P_WAIT_RX2;
|
|
break;
|
|
case P_WAIT_RX2:
|
|
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok
|
|
&& g_rx[IDX_C].frames == 0 && !g_rx[IDX_C].fin_seen)
|
|
t->phase = P_REENABLE_C;
|
|
break;
|
|
case P_REENABLE_C:
|
|
radio_set_active(C, gid, 1);
|
|
t->phase = P_WAIT_REENABLE_C;
|
|
break;
|
|
case P_WAIT_REENABLE_C:
|
|
if (node_radio(A, nc) && node_radio(B, nc)) t->phase = P_TALK_STALLED;
|
|
break;
|
|
case P_TALK_STALLED: {
|
|
const uint8_t opus[] = {0xf8, 0xff, 0xfe};
|
|
memset(g_rx, 0, sizeof(g_rx));
|
|
if (radio_talk_begin(A, gid) < 0 || radio_talk_send(A, gid, opus, sizeof(opus)) < 0) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: cannot start burst without FIN");
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->phase = P_WAIT_STALLED_START;
|
|
break;
|
|
}
|
|
case P_WAIT_STALLED_START:
|
|
if (g_rx[IDX_B].frames && g_rx[IDX_C].frames) {
|
|
if (!g_talk_on[IDX_B] || !g_talk_on[IDX_C] || g_member_event_bad) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: missing talk START B=%d C=%d bad=%d",
|
|
g_talk_on[IDX_B], g_talk_on[IDX_C], g_member_event_bad);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
radio_set_active(C, gid, 0);
|
|
if (g_talk_on[IDX_C] || !g_talk_on[IDX_B]) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: subscription stop did not clear only C: B=%d C=%d",
|
|
g_talk_on[IDX_B], g_talk_on[IDX_C]);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->phase = P_WAIT_STALLED_STOP;
|
|
}
|
|
break;
|
|
case P_WAIT_STALLED_STOP:
|
|
if (!g_talk_on[IDX_B]) {
|
|
if (g_rx[IDX_B].fin_seen || g_rx[IDX_C].fin_seen || g_member_event_bad) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: stopped indication without FIN check failed B_fin=%d C_fin=%d bad=%d",
|
|
g_rx[IDX_B].fin_seen, g_rx[IDX_C].fin_seen, g_member_event_bad);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "test: talk STOP without FIN verified (C unsubscribe, B timeout)");
|
|
radio_talk_end(A, gid);
|
|
radio_set_active(C, gid, 1);
|
|
t->phase = P_WAIT_RESTORED_C;
|
|
}
|
|
break;
|
|
case P_WAIT_RESTORED_C:
|
|
if (node_radio(A, nc) && node_radio(B, nc)) t->phase = P_WITHDRAW_C;
|
|
break;
|
|
case P_WITHDRAW_C: {
|
|
struct TOPO_GROUP* group = topo_groups_find(A->topo_groups, gid);
|
|
struct TOPO_GROUP_NODE* node = topo_node_find_by_id(group, nc);
|
|
while (node && node->paths) {
|
|
struct ETCP_CONN* from = ((struct TOPO_NODEPATH*)node->paths->head)->conn;
|
|
struct TOPOMSG_WITHDRAW_PKT withdraw = { .node_id = nc };
|
|
if (topo_group_process_withdraw(group, from, (const uint8_t*)&withdraw, sizeof(withdraw)) < 0) {
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
node = topo_node_find_by_id(group, nc);
|
|
}
|
|
if (!radio_is_active(C, gid) || !check_member_radios(A, IDX_A, 0)) {
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->phase = P_DONE;
|
|
break;
|
|
}
|
|
case P_DONE:
|
|
if (g_member_event_bad || g_talk_starts[IDX_B] != 4 || g_talk_stops[IDX_B] != 4 ||
|
|
g_talk_starts[IDX_C] != 3 || g_talk_stops[IDX_C] != 3 || g_talk_on[IDX_B] || g_talk_on[IDX_C]) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "test: talk lifecycle mismatch B=%d/%d C=%d/%d on=%d/%d bad=%d",
|
|
g_talk_starts[IDX_B], g_talk_stops[IDX_B], g_talk_starts[IDX_C], g_talk_stops[IDX_C],
|
|
g_talk_on[IDX_B], g_talk_on[IDX_C], g_member_event_bad);
|
|
t->result = 2; uasync_stop(t->ua); return;
|
|
}
|
|
t->result = 1;
|
|
uasync_stop(t->ua);
|
|
return;
|
|
}
|
|
|
|
if (++t->ticks > MAX_TICKS) {
|
|
fprintf(stderr, "test: timeout in phase %d (B_rx=%d/%d fin=%d seqok=%d C_rx=%d/%d fin=%d seqok=%d radio_flags: A(B=%d C=%d) B(A=%d C=%d) C(A=%d B=%d))\n",
|
|
(int)t->phase,
|
|
g_rx[IDX_B].frames, N_FRAMES, g_rx[IDX_B].fin_seen, g_rx[IDX_B].seq_ok,
|
|
g_rx[IDX_C].frames, N_FRAMES, g_rx[IDX_C].fin_seen, g_rx[IDX_C].seq_ok,
|
|
node_radio(A, nb), node_radio(A, nc), node_radio(B, na), node_radio(B, nc),
|
|
node_radio(C, na), node_radio(C, nb));
|
|
t->result = 2;
|
|
uasync_stop(t->ua);
|
|
return;
|
|
}
|
|
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, rad_tick, "rad_tick");
|
|
}
|
|
|
|
/* ── main ── */
|
|
|
|
int main(int argc, char** argv) {
|
|
(void)argc; (void)argv;
|
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR);
|
|
if (getenv("UTUN_TEST_DEBUG")) {
|
|
debug_set_category_level_by_name("radio", "debug");
|
|
debug_set_category_level_by_name("bgp", "info");
|
|
debug_set_category_level_by_name("member_sync", "info");
|
|
debug_set_category_level_by_name("chat_sync", "info");
|
|
}
|
|
test_clock_init(TICK_TB);
|
|
test_clock_sync_wall_time();
|
|
utun_instance_set_tun_init_enabled(0);
|
|
srand((unsigned)time(NULL));
|
|
|
|
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_radio_XXXXXX");
|
|
if (test_mkdtemp(dir) != 0) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
|
|
test_setenv("UTUN_TEST_DIR", dir, 1);
|
|
|
|
fill_shared();
|
|
write_configs(dir);
|
|
|
|
printf("=== test_radio ===\n"); fflush(stdout);
|
|
|
|
TEST("member snapshot: short names have zero padding"); {
|
|
const char* names[] = {"", "0x06B9", "Без имени", "123456789012345678901234567890123456789012345678901234567890123"};
|
|
int valid = 1;
|
|
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
|
|
struct chat_member_display member = {0};
|
|
member.node_id = UINT64_C(0x212732e6bc9c06b9);
|
|
memset(member.name, 0xFF, sizeof(member.name));
|
|
snprintf(member.name, sizeof(member.name), "%s", names[i]);
|
|
uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE];
|
|
chat_core_serialize_member(&member, wire);
|
|
char expected[64] = {0};
|
|
snprintf(expected, sizeof(expected), "%s", names[i]);
|
|
if (memcmp(wire + 15, expected, sizeof(expected))) { valid = 0; break; }
|
|
}
|
|
if (valid) OK(); else FAIL("name field contains bytes after NUL");
|
|
}
|
|
|
|
TEST("radio: members + mute relay + speaker FIN/timeout/off + withdraw"); {
|
|
struct UASYNC* ua = uasync_create();
|
|
if (!ua) { FAIL("uasync_create failed"); return 1; }
|
|
|
|
struct rad_ctx t;
|
|
memset(&t, 0, sizeof(t));
|
|
t.ua = ua;
|
|
t.phase = P_WAIT_CONN;
|
|
|
|
char cfgA[512], cfgB[512], cfgC[512];
|
|
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir);
|
|
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir);
|
|
snprintf(cfgC, sizeof(cfgC), "%s/c.conf", dir);
|
|
|
|
t.inst[IDX_A] = utun_instance_create(ua, cfgA);
|
|
t.inst[IDX_B] = utun_instance_create(ua, cfgB);
|
|
t.inst[IDX_C] = utun_instance_create(ua, cfgC);
|
|
if (!t.inst[IDX_A] || !t.inst[IDX_B] || !t.inst[IDX_C]) {
|
|
FAIL("instance create failed A=%p B=%p C=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B], (void*)t.inst[IDX_C]);
|
|
uasync_destroy(ua, 0);
|
|
return 1;
|
|
}
|
|
test_etcp_set_peer(t.inst[IDX_B], "to_a", t.inst[IDX_A], "s1");
|
|
test_etcp_set_peer(t.inst[IDX_C], "to_b", t.inst[IDX_B], "s1");
|
|
utun_instance_init(t.inst[IDX_A]);
|
|
utun_instance_init(t.inst[IDX_B]);
|
|
utun_instance_init(t.inst[IDX_C]);
|
|
memset(g_member_radio, -1, sizeof(g_member_radio));
|
|
for (int i = 0; i < 3; i++) chat_event_set_handler(t.inst[i], member_event);
|
|
|
|
radio_set_frame_cb(t.inst[IDX_A], rx_frame_cb, &g_rx[IDX_A]);
|
|
radio_set_frame_cb(t.inst[IDX_B], rx_frame_cb, &g_rx[IDX_B]);
|
|
radio_set_frame_cb(t.inst[IDX_C], rx_frame_cb, &g_rx[IDX_C]);
|
|
|
|
t.timer = uasync_set_timeout(ua, TICK_TB, &t, rad_tick, "rad_tick");
|
|
test_clock_mainloop(ua);
|
|
|
|
int ok = (t.result == 1);
|
|
|
|
if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]);
|
|
if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]);
|
|
if (t.inst[IDX_C]) utun_instance_destroy(t.inst[IDX_C]);
|
|
uasync_destroy(ua, 0);
|
|
|
|
if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase);
|
|
}
|
|
|
|
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd);
|
|
|
|
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED);
|
|
test_clock_finish();
|
|
return G_FAILED > 0 ? 1 : 0;
|
|
}
|