From 7593120bd90ecc0191fb35a01f55056dfce604e2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:06:51 +0100 Subject: [PATCH 01/45] Parse CIDR, control endpoints and storage sizes without truncation --- src/config_parser.c | 145 +++++++++++++++++++++----------------------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 8c58e059..f2c00508 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -52,56 +52,58 @@ typedef enum { } section_type_t; static int assign_string(char *dest, size_t dest_size, const char *src) { - if (!dest || !src || strlen(src) >= dest_size) return -1; + if (!dest || !src || strlen(src) >= dest_size) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "configuration string exceeds capacity=%zu or has a missing argument", dest_size); + return -1; + } strcpy(dest, src); return 0; } +/* Полное беззнаковое число без знака и хвоста; значение меняется только после всех проверок. */ +static int parse_unsigned(const char* text, int base, uint64_t maximum, uint64_t* out) { + char* end; + errno = 0; + unsigned long long value = strtoull(text, &end, base); + if ((base == 16 ? config_ini_nibble((unsigned char)*text) < 0 : !isdigit((unsigned char)*text)) || + end == text || *end || errno || value > maximum) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid unsigned base-%d value '%s', maximum=%llu", base, text, (unsigned long long)maximum); + return -1; + } + *out = value; return 0; +} + static int parse_ip_with_netmask(const char *str, struct IP *ip, uint8_t *netmask) { char ip_str[64]; - strncpy(ip_str, str, sizeof(ip_str) - 1); - ip_str[sizeof(ip_str) - 1] = '\0'; - + if (assign_string(ip_str, sizeof(ip_str), str)) return -1; char *slash = strchr(ip_str, '/'); - if (slash) { - *slash = '\0'; - *netmask = atoi(slash + 1); - } else { - *netmask = 32; // Default IPv4 netmask + if (slash) *slash++ = '\0'; + struct IP parsed = {0}; + unsigned maximum; + if (inet_pton(AF_INET, ip_str, &parsed.addr.v4) == 1) { parsed.family = AF_INET; maximum = 32; } + else if (inet_pton(AF_INET6, ip_str, &parsed.addr.v6) == 1) { parsed.family = AF_INET6; maximum = 128; } + else { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid IP address: %s", str); return -1; } - - // Try IPv4 first - struct in_addr addr4; - if (inet_pton(AF_INET, ip_str, &addr4) == 1) { - ip->family = AF_INET; - ip->addr.v4 = addr4; - return 0; - } - - // Try IPv6 - struct in6_addr addr6; - if (inet_pton(AF_INET6, ip_str, &addr6) == 1) { - ip->family = AF_INET6; - ip->addr.v6 = addr6; - if (*netmask == 32) *netmask = 128; // Default IPv6 netmask - return 0; - } - - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "parse_ip_with_netmask: invalid IP address format: %s", str); - return -1; + uint64_t prefix = maximum; + if (slash && parse_unsigned(slash, 10, maximum, &prefix)) return -1; + *ip = parsed; *netmask = (uint8_t)prefix; return 0; } static int parse_sockaddr(const char *addr_str, const char *port_str, struct sockaddr_storage *sockaddr) { struct addrinfo hints = {0}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; + hints.ai_flags = AI_NUMERICSERV; + uint64_t port; + if (!addr_str[0] || parse_unsigned(port_str, 10, UINT16_MAX, &port)) return -1; char clean_addr[MAX_ADDR_LEN]; const char *a = addr_str; if (addr_str[0] == '[') { size_t len = strlen(addr_str); if (len > 2 && addr_str[len - 1] == ']') { - if (len - 2 >= sizeof(clean_addr)) return -1; + if (len - 2 >= sizeof(clean_addr)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "bracketed address is too long"); return -1; } memcpy(clean_addr, addr_str + 1, len - 2); clean_addr[len - 2] = '\0'; a = clean_addr; @@ -109,10 +111,15 @@ static int parse_sockaddr(const char *addr_str, const char *port_str, struct soc } struct addrinfo *result; - if (getaddrinfo(a, port_str, &hints, &result) != 0) { + int rc = getaddrinfo(a, port_str, &hints, &result); + if (rc != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot resolve configured endpoint host=%s port=%s error=%d", a, port_str, rc); return -1; } - + if (!result || result->ai_addrlen > sizeof(*sockaddr)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "resolved endpoint has an invalid address size"); freeaddrinfo(result); return -1; + } + memset(sockaddr, 0, sizeof(*sockaddr)); memcpy(sockaddr, result->ai_addr, result->ai_addrlen); freeaddrinfo(result); return 0; @@ -120,11 +127,10 @@ static int parse_sockaddr(const char *addr_str, const char *port_str, struct soc static int parse_address_and_port(const char *str, struct sockaddr_storage *sockaddr) { char addr_copy[MAX_ADDR_LEN]; - if (strlen(str) >= sizeof(addr_copy)) return -1; - strcpy(addr_copy, str); + if (assign_string(addr_copy, sizeof(addr_copy), str)) return -1; char *port_str = strrchr(addr_copy, ':'); - if (!port_str) return -1; + if (!port_str) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "endpoint requires host:port: %s", str); return -1; } *port_str = '\0'; port_str++; @@ -151,6 +157,7 @@ static struct CFG_CLIENT_LINK* create_client_link(struct CFG_SERVER* local_srv, } link->local_srv = local_srv; + if (local_srv) snprintf(link->server_name, sizeof(link->server_name), "%s", local_srv->name); if (parse_address_and_port(remote_addr, &link->remote_addr) < 0) { u_free(link); @@ -271,31 +278,19 @@ static int parse_firewall_rule(const char *rule_str, struct global_config *globa } static int parse_control_allow(const char *rule_str, struct global_config *global) { - if (!rule_str || !global) return -1; - - char rule_copy[64]; - strncpy(rule_copy, rule_str, sizeof(rule_copy) - 1); - rule_copy[sizeof(rule_copy) - 1] = '\0'; - config_ini_trim(rule_copy); - - uint8_t cidr = 32; - char *slash = strchr(rule_copy, '/'); - if (slash) { - *slash = '\0'; - cidr = (uint8_t)atoi(slash + 1); - if (cidr > 32) cidr = 32; - } - - struct in_addr addr; - if (inet_pton(AF_INET, rule_copy, &addr) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "parse_control_allow: invalid IP: %s", rule_str); + struct IP address; + uint8_t cidr; + if (parse_ip_with_netmask(rule_str, &address, &cidr) || address.family != AF_INET) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "control allow requires IPv4[/0..32]: %s", rule_str); return -1; } - - uint32_t ip = ntohl(addr.s_addr); - uint32_t mask = (cidr == 32) ? 0xFFFFFFFF : (~0U << (32 - cidr)); + uint32_t ip = ntohl(address.addr.v4.s_addr); + uint32_t mask = cidr ? UINT32_MAX << (32 - cidr) : 0; if ((global->control_allow_count % INITIAL_ARRAY_CAPACITY) == 0) { + if ((uint32_t)global->control_allow_count > UINT32_MAX / sizeof(struct CFG_CONTROL_ALLOW) - INITIAL_ARRAY_CAPACITY) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "too many control allow rules"); return -1; + } int new_cap = global->control_allow_count + INITIAL_ARRAY_CAPACITY; struct CFG_CONTROL_ALLOW *new_allows = u_realloc(global->control_allows, new_cap * sizeof(struct CFG_CONTROL_ALLOW)); @@ -461,21 +456,19 @@ static int parse_global(const char *key, const char *value, struct global_config static int parse_control(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { if (strcmp(key, "ip") == 0 || strcmp(key, "control_ip") == 0) { - strncpy(global->control_ip, value, sizeof(global->control_ip) - 1); - global->control_ip[sizeof(global->control_ip) - 1] = '\0'; + if (assign_string(global->control_ip, sizeof(global->control_ip), value)) return -1; + if (global->control_sock.ss_family) { + uint16_t port = global->control_sock.ss_family == AF_INET ? + ntohs(((struct sockaddr_in*)&global->control_sock)->sin_port) : + ntohs(((struct sockaddr_in6*)&global->control_sock)->sin6_port); + char port_str[6]; snprintf(port_str, sizeof(port_str), "%u", port); + return parse_sockaddr(value, port_str, &global->control_sock); + } return 0; } if (strcmp(key, "port") == 0 || strcmp(key, "control_port") == 0) { - char control_ip[MAX_ADDR_LEN]; - if (global->control_ip[0] != '\0') { - strncpy(control_ip, global->control_ip, sizeof(control_ip) - 1); - } else { - strcpy(control_ip, "127.0.0.1"); - } - char port_str[16]; - snprintf(port_str, sizeof(port_str), "%s", value); - parse_sockaddr(control_ip, port_str, &global->control_sock); - return 0; + const char* control_ip = global->control_ip[0] ? global->control_ip : "127.0.0.1"; + return parse_sockaddr(control_ip, value, &global->control_sock); } if (strcmp(key, "allow") == 0 || strcmp(key, "control_allow") == 0) { return parse_control_allow(value, global); @@ -824,15 +817,17 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c } static int parse_size_with_suffix(const char *value, uint64_t *out_bytes) { - if (!value || !out_bytes) return -1; - char *end = NULL; + char *end; + errno = 0; uint64_t num = strtoull(value, &end, 10); - if (end == value) return -1; + if (!isdigit((unsigned char)*value) || errno || 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; + uint64_t scale = 1; + if (*end == 'M' || *end == 'm') { scale = UINT64_C(1024) * 1024; end++; } + else if (*end == 'G' || *end == 'g') { scale = UINT64_C(1024) * 1024 * 1024; end++; } + while (*end && isspace((unsigned char)*end)) end++; + if (*end || num > UINT64_MAX / scale) return -1; + *out_bytes = num * scale; return 0; } static int parse_chatserver(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { From bff354f9ccc13842a897c8518793aba4408a002d Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:07:54 +0100 Subject: [PATCH 02/45] Validate complete protocol versions, network keys and endpoint ports --- src/config_parser.c | 76 ++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index f2c00508..2480e947 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -73,6 +73,26 @@ static int parse_unsigned(const char* text, int base, uint64_t maximum, uint64_t *out = value; return 0; } +/* Версия содержит ровно три десятичных октета, без хвоста; выход применяется целиком. */ +static int parse_version(const char* value, uint8_t out[3]) { + char text[12]; + if (assign_string(text, sizeof(text), value)) return -1; + char* cursor = text; + uint8_t version[3]; + for (unsigned i = 0; i < 3; i++) { + char* dot = strchr(cursor, '.'); + if ((i < 2 && !dot) || (i == 2 && dot)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid version format, expected x.y.z"); return -1; + } + if (dot) *dot = '\0'; + uint64_t number; + if (parse_unsigned(cursor, 10, UINT8_MAX, &number)) return -1; + version[i] = (uint8_t)number; + if (dot) cursor = dot + 1; + } + memcpy(out, version, sizeof(version)); return 0; +} + static int parse_ip_with_netmask(const char *str, struct IP *ip, uint8_t *netmask) { char ip_str[64]; if (assign_string(ip_str, sizeof(ip_str), str)) return -1; @@ -668,26 +688,18 @@ static int parse_nat(const char *key, const char *value, struct global_config *g static int parse_server(const char *key, const char *value, struct CFG_SERVER *srv, const char *filename, int line_num) { if (strcmp(key, "addr") == 0) { - if (strncmp(value, "temporary_ipv6:", 15) == 0) { - srv->ipv6_mode = CFG_IPV6_MODE_TEMPORARY; - const char* port_str = value + 15; - if (!port_str[0]) return -1; + int temporary = strncmp(value, "temporary_ipv6:", 15) == 0; + if (temporary || strncmp(value, "permanent_ipv6:", 15) == 0) { + uint64_t port; + if (parse_unsigned(value + 15, 10, UINT16_MAX, &port)) return -1; + srv->ipv6_mode = temporary ? CFG_IPV6_MODE_TEMPORARY : CFG_IPV6_MODE_PERMANENT; struct sockaddr_in6* sin6 = (struct sockaddr_in6*)&srv->ip; memset(&srv->ip, 0, sizeof(srv->ip)); sin6->sin6_family = AF_INET6; - sin6->sin6_port = htons((uint16_t)atoi(port_str)); - return 0; - } - if (strncmp(value, "permanent_ipv6:", 15) == 0) { - srv->ipv6_mode = CFG_IPV6_MODE_PERMANENT; - const char* port_str = value + 15; - if (!port_str[0]) return -1; - struct sockaddr_in6* sin6 = (struct sockaddr_in6*)&srv->ip; - memset(&srv->ip, 0, sizeof(srv->ip)); - sin6->sin6_family = AF_INET6; - sin6->sin6_port = htons((uint16_t)atoi(port_str)); + sin6->sin6_port = htons((uint16_t)port); return 0; } + srv->ipv6_mode = CFG_IPV6_MODE_NONE; return parse_address_and_port(value, &srv->ip); } if (strcmp(key, "so_mark") == 0) { @@ -712,7 +724,7 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s } else if (strcmp(value, "local") == 0) { srv->type = CFG_SERVER_TYPE_LOCAL; } else { - srv->type = CFG_SERVER_TYPE_UNKNOWN; + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown server type '%s'", filename, line_num, value); return -1; } return 0; } @@ -726,7 +738,8 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s } if (strcmp(key, "transport") == 0) { if (strcmp(value, "tcp") == 0) srv->transport = 1; - else srv->transport = 0; // default udp + else if (strcmp(value, "udp") == 0) srv->transport = 0; + else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown server transport '%s'", filename, line_num, value); return -1; } return 0; } if (strcmp(key, "reality") == 0) { @@ -798,13 +811,7 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c return 0; } if (strcmp(key, "version") == 0) { - int x = 0, y = 0, z = 0; - if (sscanf(value, "%d.%d.%d", &x, &y, &z) != 3 || x < 0 || x > 255 || y < 0 || y > 255 || z < 0 || z > 255) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: bad client reality version '%s' (expected x.y.z)", filename, line_num, value); - return -1; - } - cli->reality.version[0] = (uint8_t)x; cli->reality.version[1] = (uint8_t)y; cli->reality.version[2] = (uint8_t)z; - return 0; + return parse_version(value, cli->reality.version); } if (strcmp(key, "fingerprint") == 0) { if (strcmp(value, "openssl") == 0) { cli->reality.fingerprint = REALITY_FP_OPENSSL; return 0; } @@ -879,13 +886,14 @@ static int parse_chatserver(const char *key, const char *value, struct global_co 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); - return 0; + return parse_unsigned(value, 16, UINT64_C(0x00ffffffffffffff), &net->id); } if (strcmp(key, "pubkey") == 0) { + if (config_ini_hex(value, NULL, SC_PUBKEY_SIZE)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "network pubkey requires 64 HEX characters"); return -1; } return assign_string(net->pubkey_hex, sizeof(net->pubkey_hex), value); } if (strcmp(key, "signing_key") == 0) { + if (config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "network signing key requires 64 HEX characters"); return -1; } return assign_string(net->signing_key_hex, sizeof(net->signing_key_hex), value); } DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown network option '%s'. Valid: id, pubkey, signing_key", filename, line_num, key); @@ -930,13 +938,7 @@ static int parse_reality(const char *key, const char *value, struct reality_conf return 0; } if (strcmp(key, "version") == 0) { - int x = 0, y = 0, z = 0; - if (sscanf(value, "%d.%d.%d", &x, &y, &z) != 3 || x < 0 || x > 255 || y < 0 || y > 255 || z < 0 || z > 255) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: bad reality version '%s' (expected x.y.z)", filename, line_num, value); - return -1; - } - rc->version[0] = (uint8_t)x; rc->version[1] = (uint8_t)y; rc->version[2] = (uint8_t)z; - return 0; + return parse_version(value, rc->version); } if (strcmp(key, "time_window") == 0) { rc->time_window_sec = atoi(value); return 0; } if (strcmp(key, "relay_idle_timeout") == 0) { rc->relay_idle_timeout_sec = atoi(value); return 0; } @@ -958,11 +960,13 @@ static int parse_socks(const char *key, const char *value, struct global_config if (strcmp(key, "addr") == 0) { const char *colon = strrchr(value, ':'); if (!colon) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: socks addr must be 'host:port'", filename, line_num); return -1; } - int port = atoi(colon + 1); - if (port <= 0 || port > 65535) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: bad socks port '%s'", filename, line_num, colon + 1); return -1; } + uint64_t port; + if (parse_unsigned(colon + 1, 10, UINT16_MAX, &port) || !port) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: bad socks port", filename, line_num); return -1; + } char host[64]; size_t hlen = (size_t)(colon - value); - if (hlen >= sizeof(host)) hlen = sizeof(host) - 1; + if (!hlen || hlen >= sizeof(host)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid socks host length", filename, line_num); return -1; } memcpy(host, value, hlen); host[hlen] = '\0'; if (hlen >= 2 && host[0] == '[' && host[hlen - 1] == ']') { host[hlen - 1] = '\0'; memmove(host, host + 1, strlen(host)); } strncpy(global->socks_host, host, sizeof(global->socks_host) - 1); From f581fa423ba1b58095f347383457591763524161 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:08:40 +0100 Subject: [PATCH 03/45] Reject configuration when an option handler or allocation fails --- src/config_parser.c | 67 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 2480e947..081af1c2 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -1130,6 +1130,12 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha if (cur_section == SECTION_NETWORK && cur_network) { cur_network->next = cfg->networks; cfg->networks = cur_network; cur_network = NULL; } char name[MAX_CONN_NAME_LEN]; cur_section = parse_section_header(trimmed, name, sizeof(name)); + if (cur_section == SECTION_UNKNOWN) + DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown or malformed section ignored", filename, line_num); + if (cur_section == SECTION_NAT) cfg->global.nat_enabled = 1; + if (cur_section == SECTION_TCP_PROXY_CLIENT) cfg->global.tcp_proxy_client_enabled = 1; + if (cur_section == SECTION_TCP_PROXY_SERVER) cfg->global.tcp_proxy_server_enabled = 1; + if (cur_section == SECTION_CHATSERVER) { cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; } if (cur_section == SECTION_SERVER) { cur_server = u_calloc(1, sizeof(struct CFG_SERVER)); if (!cur_server) goto error; strcpy(cur_server->name, name); cur_server->fib = -1; } else if (cur_section == SECTION_CLIENT) { cur_client = u_calloc(1, sizeof(struct CFG_CLIENT)); if (!cur_client) goto error; strcpy(cur_client->name, name); reality_client_config_set_defaults(&cur_client->reality); } else if (cur_section == SECTION_NETWORK) { cur_network = u_calloc(1, sizeof(struct CFG_NETWORK)); if (!cur_network) goto error; strcpy(cur_network->name, name); } @@ -1138,9 +1144,9 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha char key[MAX_LINE_LEN], value[MAX_LINE_LEN]; if (config_ini_key_value(trimmed, key, sizeof(key), value, sizeof(value)) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Invalid key=value format", filename, line_num); - if (cur_section == SECTION_FIREWALL || cur_section == SECTION_NAT) goto error; - continue; + goto error; } + int option_result = 0; switch (cur_section) { case SECTION_GLOBAL: if (parse_global(key, value, &cfg->global, filename, line_num) < 0) { @@ -1148,9 +1154,13 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha goto error; } break; - case SECTION_SERVER: if (cur_server) parse_server(key, value, cur_server, filename, line_num); break; - case SECTION_CLIENT: if (cur_client) parse_client(key, value, cur_client, cfg->servers, filename, line_num); break; - case SECTION_ROUTING: if (strcmp(key, "route_subnet") == 0) add_route_entry(&cfg->route_subnets, value); else if (strcmp(key, "my_subnet") == 0) add_route_entry(&cfg->my_subnets, value); else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown routing option '%s'", filename, line_num, key); break; + case SECTION_SERVER: option_result = parse_server(key, value, cur_server, filename, line_num); break; + case SECTION_CLIENT: option_result = parse_client(key, value, cur_client, cfg->servers, filename, line_num); break; + case SECTION_ROUTING: + if (strcmp(key, "route_subnet") == 0) option_result = add_route_entry(&cfg->route_subnets, value); + else if (strcmp(key, "my_subnet") == 0) option_result = add_route_entry(&cfg->my_subnets, value); + else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown routing option '%s'", filename, line_num, key); goto error; } + break; case SECTION_DEBUG: if (cfg->global.debug_levels.count < DEBUG_CATEGORY_COUNT) { int index = cfg->global.debug_levels.count++; @@ -1168,15 +1178,23 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha goto error; } break; - case SECTION_CONTROL: parse_control(key, value, &cfg->global, filename, line_num); break; - case SECTION_ALLOWED_KEYS: if (strcmp(key, "allow_all") == 0) cfg->global.allowed_keys_allow_all = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "key") == 0) { struct CFG_ALLOWED_KEY *nk = u_calloc(1, sizeof(struct CFG_ALLOWED_KEY)); if (nk && strlen(value) == SC_PUBKEY_SIZE * 2 && config_ini_hex(value, nk->key_bin, SC_PUBKEY_SIZE) == 0) { nk->next = cfg->global.allowed_keys; cfg->global.allowed_keys = nk; cfg->global.allowed_keys_count++; } else u_free(nk); } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown allowed_keys option '%s'", filename, line_num, key); break; + case SECTION_CONTROL: option_result = parse_control(key, value, &cfg->global, filename, line_num); break; + case SECTION_ALLOWED_KEYS: + if (strcmp(key, "allow_all") == 0) + cfg->global.allowed_keys_allow_all = !strcasecmp(value, "yes") || !strcmp(value, "1") || !strcasecmp(value, "true"); + else if (strcmp(key, "key") == 0) { + struct CFG_ALLOWED_KEY* nk = u_calloc(1, sizeof(*nk)); + if (!nk) goto error; + if (config_ini_hex(value, nk->key_bin, SC_PUBKEY_SIZE)) { u_free(nk); goto error; } + nk->next = cfg->global.allowed_keys; cfg->global.allowed_keys = nk; cfg->global.allowed_keys_count++; + } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown allowed_keys option '%s'", filename, line_num, key); goto error; } + break; case SECTION_NAT: cfg->global.nat_enabled = 1; if (parse_nat(key, value, &cfg->global, filename, line_num) < 0) goto error; break; - case SECTION_TCP_PROXY_CLIENT: cfg->global.tcp_proxy_client_enabled = 1; parse_tcp_proxy_client(key, value, &cfg->global, filename, line_num); break; + case SECTION_TCP_PROXY_CLIENT: option_result = parse_tcp_proxy_client(key, value, &cfg->global, filename, line_num); break; case SECTION_TCP_PROXY_SERVER: - cfg->global.tcp_proxy_server_enabled = 1; if (strcmp(key, "tcp_recv_buf") == 0) { cfg->global.tcp_recv_buf = atoi(value); } else if (strcmp(key, "fib") == 0) { @@ -1192,11 +1210,28 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha cfg->global.tcp_proxy_server_connect_timeout_ms = atoi(value); } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown tcp_proxy_server option '%s'. Valid: tcp_recv_buf, fib, so_mark, netif, bind_ip, connect_timeout", filename, line_num, key); + goto error; } break; - case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; - case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; - case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, radio_compressor_enabled, radio_compressor_max_gain_db, media_download_max_peers", filename, line_num, key); } break; + case SECTION_NETWORK: option_result = 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") || !strcmp(value, "1") || !strcasecmp(value, "true"); + else if (strcmp(key, "server") == 0) { + struct CFG_NTP_SERVER* ns = u_calloc(1, sizeof(*ns)); + if (!ns) goto error; + if (assign_string(ns->name, sizeof(ns->name), value) || !value[0]) { u_free(ns); goto error; } + 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); goto error; } + break; + case SECTION_CHATSERVER: + if (strcmp(key, "db_path") == 0) option_result = assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); + else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && + chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) goto error; + break; case SECTION_GUI: if (!strcasecmp(key, "utun_enabled")) { if (!strcasecmp(value, "yes") || !strcasecmp(value, "true") || !strcmp(value, "1")) @@ -1206,8 +1241,8 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid gui utun_enabled '%s'", filename, line_num, value); goto error; } } break; - case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break; - case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break; + case SECTION_REALITY: option_result = parse_reality(key, value, &cfg->global.reality, filename, line_num); break; + case SECTION_SOCKS: option_result = parse_socks(key, value, &cfg->global, filename, line_num); break; case SECTION_STUN: if (parse_stun(key, value, &cfg->global, filename, line_num) < 0) goto error; break; case SECTION_LOG_UDP: if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1); @@ -1215,6 +1250,9 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha break; default: DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: Key outside section: %s", filename, line_num, key); break; } + if (option_result < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: rejected option '%s'", filename, line_num, key); goto error; + } } if (cfg->global.stun_enabled && cfg->global.stun_server_count < 2) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: enabled STUN requires at least two servers", filename); @@ -1228,6 +1266,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha else { cfg->global.tun_enabled = 1; if (cfg->global.tun_ifname[0] == '\0') { snprintf(cfg->global.tun_ifname, sizeof(cfg->global.tun_ifname), "tun0"); } } return cfg; error: + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: configuration rejected; releasing partial model", filename, line_num); if (cur_server) u_free(cur_server); if (cur_client) { free_cfg_client_links(cur_client->links); u_free(cur_client); } if (cur_network) u_free(cur_network); From d21af324ac7e42268fe15c376c076f2512e40092 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:09:18 +0100 Subject: [PATCH 04/45] Validate network keys before use and keep secret keys out of dumps --- src/config_parser.c | 50 +++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 081af1c2..450bc156 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -1258,9 +1258,14 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: enabled STUN requires at least two servers", filename); goto error; } - if (cur_section == SECTION_SERVER && cur_server) { cur_server->next = cfg->servers; cfg->servers = cur_server; } - if (cur_section == SECTION_CLIENT && cur_client) { cur_client->next = cfg->clients; cfg->clients = cur_client; } - if (cur_section == SECTION_NETWORK && cur_network) { cur_network->next = cfg->networks; cfg->networks = cur_network; } + if (cur_section == SECTION_SERVER && cur_server) { cur_server->next = cfg->servers; cfg->servers = cur_server; cur_server = NULL; } + if (cur_section == SECTION_CLIENT && cur_client) { cur_client->next = cfg->clients; cfg->clients = cur_client; cur_client = NULL; } + if (cur_section == SECTION_NETWORK && cur_network) { cur_network->next = cfg->networks; cfg->networks = cur_network; cur_network = NULL; } + for (struct CFG_NETWORK* net = cfg->networks; net; net = net->next) { + if (config_ini_hex(net->pubkey_hex, NULL, SC_PUBKEY_SIZE) || config_ini_hex(net->signing_key_hex, NULL, SC_PRIVKEY_SIZE)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: network '%s' requires both 32-byte HEX keys", filename, net->name); goto error; + } + } if (!cfg->global.tun_enabled && cfg->global.tun_ifname[0] == '\0' && cfg->global.tun_ip.family == AF_UNSPEC) {} else if (!cfg->global.tun_enabled) {} else { cfg->global.tun_enabled = 1; if (cfg->global.tun_ifname[0] == '\0') { snprintf(cfg->global.tun_ifname, sizeof(cfg->global.tun_ifname), "tun0"); } } @@ -1362,8 +1367,8 @@ void print_config(const struct utun_config *cfg) { const struct global_config *g = &cfg->global; char ip_buffer[64]; - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Global: priv_key=%s, pub_key=%s, node_id=%llx, tun_if=%s, tun_ip=%s, mtu=%d, keepalive=%d, test_mode=%d", - g->my_private_key_hex, g->my_public_key_hex, + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Global: private_key_set=%d, pub_key=%s, node_id=%llx, tun_if=%s, tun_ip=%s, mtu=%d, keepalive=%d, test_mode=%d", + g->my_private_key_hex[0] != '\0', g->my_public_key_hex, (unsigned long long)g->my_node_id, g->tun_ifname[0] ? g->tun_ifname : "auto", ip_to_string(&g->tun_ip, ip_buffer, sizeof(ip_buffer)), @@ -1420,19 +1425,16 @@ void print_config(const struct utun_config *cfg) { DEBUG_INFO(DEBUG_CATEGORY_CONFIG, " allow=%s", ip_str); } else { DEBUG_INFO(DEBUG_CATEGORY_CONFIG, " allow=%s:%d", ip_str, r->port); - } - - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Networks:"); - struct CFG_NETWORK *net = cfg->networks; - while (net) { - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, " [network:%s] id=%012llx pubkey=%.64s signing_key=%.64s", - net->name, (unsigned long long)net->id, net->pubkey_hex, net->signing_key_hex); - net = net->next; - } -} + } + } } else { DEBUG_INFO(DEBUG_CATEGORY_CONFIG, " no rules"); } + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Networks:"); + for (const struct CFG_NETWORK* net = cfg->networks; net; net = net->next) { + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, " [network:%s] id=%014llx pubkey=%.64s signing_key_set=%d", + net->name, (unsigned long long)net->id, net->pubkey_hex, net->signing_key_hex[0] != '\0'); + } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Control allows: %d rules (default deny all if 0)", g->control_allow_count); @@ -1588,16 +1590,16 @@ void print_chatserver_invite_link(const struct utun_config *config, const struct } int update_config_keys(const char *filename, const char *priv_key, const char *pub_key) { - // Minimal implementation: just append to file + if (!filename || !*filename || config_ini_hex(priv_key, NULL, SC_PRIVKEY_SIZE) || config_ini_hex(pub_key, NULL, SC_PUBKEY_SIZE)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "update_config_keys requires a path and two 32-byte HEX keys"); return -1; + } FILE *fp = fopen(filename, "a"); - if (!fp) return -1; - - fprintf(fp, "\n[global]\n"); - fprintf(fp, "my_private_key=%s\n", priv_key); - fprintf(fp, "my_public_key=%s\n", pub_key); - - fclose(fp); - return 0; + if (!fp) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot append keys to %s: %s", filename, strerror(errno)); return -1; } + int failed = fprintf(fp, "\n[global]\nmy_private_key=%s\nmy_public_key=%s\n", priv_key, pub_key) < 0; + if (failed) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot write appended keys to %s: %s", filename, strerror(errno)); + if (fclose(fp)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot close appended key file %s: %s", filename, strerror(errno)); failed = 1; } + if (!failed) DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "appended key configuration to %s", filename); + return failed ? -1 : 0; } int config_peer_in_clients(const struct utun_config* config, const uint8_t* pubkey) { From 46bf5b854140ebcf4bcb11bc498d86642491b3c8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:10:24 +0100 Subject: [PATCH 05/45] Test parser rejection, CIDR boundaries and allocation rollback --- tests/Makefile.am | 4 ++ tests/test_config_parser.c | 111 ++++++++++++++++++++++++++++++++++++ tests/test_config_updater.c | 6 +- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/test_config_parser.c diff --git a/tests/Makefile.am b/tests/Makefile.am index c7e21590..7753716f 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -286,6 +286,10 @@ test_socket_monitor_LDFLAGS = -Wl,--wrap=socket -Wl,--wrap=bind -Wl,--wrap=close -Wl,--wrap=get_default_route_netif_index -Wl,--wrap=get_outgoing_local_ip -Wl,--wrap=etcp_socket_cbk_fire if OS_LINUX test_socket_monitor_LDFLAGS += -Wl,--wrap=recvfrom -Wl,--wrap=__recvfrom_chk +check_PROGRAMS += test_config_parser +test_config_parser_SOURCES = test_config_parser.c +test_config_parser_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_config_parser_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl else test_socket_monitor_LDFLAGS += -Wl,--wrap=read endif diff --git a/tests/test_config_parser.c b/tests/test_config_parser.c new file mode 100644 index 00000000..34295144 --- /dev/null +++ b/tests/test_config_parser.c @@ -0,0 +1,111 @@ +/* Контракт INI: границы значений, единый отказ файла/буфера, rollback памяти и безопасный dump. */ +#include "config_parser.h" +#include "../lib/mem.h" +#include "../lib/debug_config.h" +#include "../lib/platform_compat.h" +#include +#include +#include + +static unsigned fail_at, allocation; +static int inject(const char* where) { return fail_at && strstr(where, "config_parser.c") && ++allocation == fail_at; } +extern void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); +extern void* __real_u_realloc_impl(void*, uint32_t, const char*); +void* __wrap_u_calloc_impl(uint32_t n, uint32_t size, const char* where) { + return inject(where) ? NULL : __real_u_calloc_impl(n, size, where); +} +void* __wrap_u_realloc_impl(void* p, uint32_t size, const char* where) { + return inject(where) ? NULL : __real_u_realloc_impl(p, size, where); +} + +static struct utun_config* parse(const char* text) { return parse_config_from_buf(text, strlen(text), "parser-contract"); } + +static void test_values(void) { + struct utun_config* cfg = parse("[routing]\nmy_subnet=::1/32\nroute_subnet=2001:db8::/0\nroute_subnet=192.0.2.0/0\n" + "[control]\nport=0\nip=127.0.0.2\nallow=0.0.0.0/0\nallow=192.0.2.123/24\n" + "[server:udp]\naddr=127.0.0.1:0\n[client:peer]\nlink=udp:127.0.0.2:65535\n" + "[chat]\nstorage_total_size=18446744073709551615\nstorage_unit_size=17179869183G\n" + "[tcp_proxy_client]\nenabled=no\nmtu=1280\n"); + assert(cfg && cfg->my_subnets->netmask == 32 && cfg->route_subnets->netmask == 0 && cfg->route_subnets->next->netmask == 0); + assert(cfg->global.control_sock.ss_family == AF_INET && + ntohl(((struct sockaddr_in*)&cfg->global.control_sock)->sin_addr.s_addr) == 0x7f000002); + assert(!cfg->global.control_allows[0].network && !cfg->global.control_allows[0].netmask); + assert(cfg->global.control_allows[1].network == 0xc0000200 && cfg->global.control_allows[1].netmask == 0xffffff00); + assert(cfg->clients->links->local_srv == cfg->servers && !strcmp(cfg->clients->links->server_name, "udp")); + assert(cfg->global.chatserver_storage_total_size == UINT64_MAX); + assert(cfg->global.chatserver_storage_unit_size == UINT64_C(17179869183) * 1024 * 1024 * 1024); + assert(!cfg->global.tcp_proxy_client_enabled); free_config(cfg); + cfg = parse("[routing]\nmy_subnet=::1\n[server:v6]\naddr=temporary_ipv6:65535\naddr=[::1]:0\n" + "[control]\nip=::1\nport=65535\n[reality]\nversion=0.255.1\n"); + assert(cfg && cfg->my_subnets->netmask == 128 && !cfg->servers->ipv6_mode); + assert(cfg->global.control_sock.ss_family == AF_INET6 && cfg->global.reality.version[1] == 255); free_config(cfg); + cfg = parse("[chat]\n[nat]\n[tcp_proxy_client]\n[tcp_proxy_server]\n"); + assert(cfg && cfg->global.chatserver_enabled && cfg->global.db_sync_enabled && cfg->global.nat_enabled && + cfg->global.tcp_proxy_client_enabled && cfg->global.tcp_proxy_server_enabled); free_config(cfg); + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "PASS IPv4/IPv6 CIDR, control order, port bounds, size limits, link names and section defaults"); +} + +static void test_rejections(void) { + const char* bad[] = { + "[routing]\nmy_subnet=::1/129\n", "[routing]\nmy_subnet=::1/32junk\n", + "[routing]\nmy_subnet=127.0.0.1/256\n", "[routing]\nmy_subnet=127.0.0.1/-1\n", + "[routing]\nmy_subnet=127.0.0.1/\n", "[routing]\nmy_subnet=127.0.0.1/33\n", + "[routing]\nwrong=1\n", "[control]\nallow=127.0.0.1/256\n", "[control]\nallow=::1/0\n", + "[control]\nport=65536\n", "[control]\nport=80x\n", "[control]\nport=\n", "[control]\nport=http\n", + "[control]\nport=1000\nip=invalid.invalid\n", "[server:s]\naddr=127.0.0.1:65536\n", + "[server:s]\naddr=temporary_ipv6:-1\n", "[server:s]\naddr=permanent_ipv6:65536\n", + "[server:s]\ntransport=typo\n", "[server:s]\ntype=typo\n", "[client:c]\nlink=broken\n", + "[client:c]\nshort_id=bad\n", "[reality]\nversion=1.2.3tail\n", "[reality]\nversion=256.0.0\n", + "[reality]\nversion=9999999999999999999999.1.1\n", "[reality]\nshort_ids=01\n", + "[socks]\naddr=:1080\n", "[socks]\naddr=127.0.0.1:1080x\n", "[socks]\naddr=127.0.0.1:-1\n", + "[chat]\nstorage_total_size=-1\n", "[chat]\nstorage_unit_size=1MB\n", + "[chat]\nstorage_total_size=18446744073709551616\n", "[chat]\nstorage_total_size=17179869184G\n", + "[allowed_keys]\nkey=bad\n", "[network:n]\nid=100000000000000\n", "[network:n]\npubkey=1g\n", + "[network:n]\nid=1\n", "[ntp]\nserver=\n", "[tcp_proxy_client]\nforward=0 -> 1.2.3.4:80\n", + "[server:s]\ninvalid\n" + }; + char path[] = "/tmp/utun-parser-contract-XXXXXX"; + int fd = mkstemp(path); assert(fd >= 0); close(fd); + for (size_t i = 0; i < sizeof(bad) / sizeof(*bad); i++) { + size_t before = u_get_allocated_count(); + assert(!parse(bad[i]) && u_get_allocated_count() == before); + FILE* file = fopen(path, "wb"); assert(file && fputs(bad[i], file) >= 0 && !fclose(file)); + assert(!parse_config(path) && u_get_allocated_count() == before); + } + unlink(path); + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "PASS %zu invalid configurations rejected identically from file/buffer without leaks", sizeof(bad) / sizeof(*bad)); +} + +static void test_allocations(void) { + const char* text = "[global]\nmy_node_name=allocation\n[server:a]\naddr=127.0.0.1:0\n[client:b]\nlink=a:127.0.0.2:1\n" + "[routing]\nmy_subnet=127.0.0.1/32\n[firewall]\nallow=127.0.0.1\n[control]\nallow=127.0.0.1/32\n" + "[allowed_keys]\nkey=0101010101010101010101010101010101010101010101010101010101010101\n" + "[ntp]\nserver=localhost\n[network:n]\nid=1\n" + "pubkey=0202020202020202020202020202020202020202020202020202020202020202\n" + "signing_key=0303030303030303030303030303030303030303030303030303030303030303\n"; + size_t baseline = u_get_allocated_count(); + unsigned tested; + for (tested = 1; tested < 64; tested++) { + allocation = 0; fail_at = tested; + struct utun_config* cfg = parse(text); + unsigned calls = allocation; fail_at = 0; + if (calls < tested) { assert(cfg); free_config(cfg); break; } + assert(!cfg && u_get_allocated_count() == baseline); + } + assert(tested > 8 && tested < 64 && u_get_allocated_count() == baseline); + struct utun_config* cfg = parse(text); assert(cfg); + strcpy(cfg->global.my_private_key_hex, "0404040404040404040404040404040404040404040404040404040404040404"); + char path[] = "/tmp/utun-parser-dump-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd); + assert(!debug_enable_file_output(path, 1)); print_config(cfg); debug_disable_file_output(); + FILE* file = fopen(path, "rb"); assert(file); + char output[8192]; size_t n = fread(output, 1, sizeof(output) - 1, file); output[n] = '\0'; fclose(file); unlink(path); + assert(strstr(output, "[network:n]") && !strstr(output, cfg->global.my_private_key_hex) && !strstr(output, cfg->networks->signing_key_hex)); + assert(update_config_keys("/dev/full", cfg->global.my_private_key_hex, cfg->networks->pubkey_hex) < 0); + free_config(cfg); assert(u_get_allocated_count() == baseline); + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "PASS %u allocation faults, redacted independent network dump and failed key append", tested - 1); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); assert(!socket_platform_init()); + test_values(); test_rejections(); test_allocations(); socket_platform_cleanup(); return 0; +} diff --git a/tests/test_config_updater.c b/tests/test_config_updater.c index 40e52689..005711b8 100644 --- a/tests/test_config_updater.c +++ b/tests/test_config_updater.c @@ -250,8 +250,7 @@ static void test_reality_and_validation(void) { snprintf(text, sizeof(text), "[global]\nmy_private_key=%s\n[REALITY]\nenabled=1\nprivate_key=%s\n" "short_ids=0102030405060708,bad\n[reality]\nshort_ids =\n", private_hex, invalid_hex); struct utun_config *config = parse_config_from_buf(text, strlen(text), "malformed"); - assert(config && !config->global.reality.has_private_key && !config->global.reality.short_id_count); - free_config(config); + assert(!config); // парсер отклоняет ошибку; updater по-прежнему исправляет исходный INI до загрузки write_text(text); assert(!config_ensure_keys_and_node_id(path)); check_identity(private_hex); @@ -285,8 +284,7 @@ static void test_reality_and_validation(void) { for (size_t i = 0; i < sizeof(bad_lists) / sizeof(*bad_lists); i++) { snprintf(text, sizeof(text), "[reality]\nshort_ids=%s\n", bad_lists[i]); config = parse_config_from_buf(text, strlen(text), "bad-list"); - assert(config && !config->global.reality.short_id_count); - free_config(config); + assert(!config); } memset(text, 'x', 1023); text[1023] = '\n'; text[1024] = '\0'; write_text(text); From 70b782399377d31bcd19a61f73f7873618046587 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:11:49 +0100 Subject: [PATCH 06/45] Use debug and socket test APIs with their declared contracts --- tests/test_config_parser.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_config_parser.c b/tests/test_config_parser.c index 34295144..b58183b4 100644 --- a/tests/test_config_parser.c +++ b/tests/test_config_parser.c @@ -3,6 +3,7 @@ #include "../lib/mem.h" #include "../lib/debug_config.h" #include "../lib/platform_compat.h" +#include "../lib/socket_compat.h" #include #include #include @@ -52,7 +53,7 @@ static void test_rejections(void) { "[routing]\nmy_subnet=127.0.0.1/\n", "[routing]\nmy_subnet=127.0.0.1/33\n", "[routing]\nwrong=1\n", "[control]\nallow=127.0.0.1/256\n", "[control]\nallow=::1/0\n", "[control]\nport=65536\n", "[control]\nport=80x\n", "[control]\nport=\n", "[control]\nport=http\n", - "[control]\nport=1000\nip=invalid.invalid\n", "[server:s]\naddr=127.0.0.1:65536\n", + "[control]\nport=1000\nip=\n", "[server:s]\naddr=127.0.0.1:65536\n", "[server:s]\naddr=temporary_ipv6:-1\n", "[server:s]\naddr=permanent_ipv6:65536\n", "[server:s]\ntransport=typo\n", "[server:s]\ntype=typo\n", "[client:c]\nlink=broken\n", "[client:c]\nshort_id=bad\n", "[reality]\nversion=1.2.3tail\n", "[reality]\nversion=256.0.0\n", @@ -96,7 +97,7 @@ static void test_allocations(void) { struct utun_config* cfg = parse(text); assert(cfg); strcpy(cfg->global.my_private_key_hex, "0404040404040404040404040404040404040404040404040404040404040404"); char path[] = "/tmp/utun-parser-dump-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd); - assert(!debug_enable_file_output(path, 1)); print_config(cfg); debug_disable_file_output(); + debug_enable_file_output(path, 1); print_config(cfg); debug_disable_file_output(); FILE* file = fopen(path, "rb"); assert(file); char output[8192]; size_t n = fread(output, 1, sizeof(output) - 1, file); output[n] = '\0'; fclose(file); unlink(path); assert(strstr(output, "[network:n]") && !strstr(output, cfg->global.my_private_key_hex) && !strstr(output, cfg->networks->signing_key_hex)); From 4abeafa20ed57bca09dfcd6db1933e69f18e0070 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:14:22 +0100 Subject: [PATCH 07/45] Normalize malformed key duplicates before strict config verification --- src/config_updater.c | 18 +++++++++++++++++- src/config_updater.h | 4 +++- src/config_updater_doc.md | 10 +++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/config_updater.c b/src/config_updater.c index fb21a652..71a26520 100644 --- a/src/config_updater.c +++ b/src/config_updater.c @@ -20,9 +20,12 @@ struct config_field { int section; int present; int update; + int invalid; // хотя бы одно вхождение поля содержит ошибочный синтаксис char value[CONFIG_INI_LINE_SIZE]; }; +static int config_node_id(const char* text, uint64_t* id); + /* Форматировать без усечения; проверка ёмкости не переполняет size_t. */ void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex_str, size_t hex_len) { static const char digits[] = "0123456789abcdef"; @@ -77,7 +80,17 @@ static void config_scan(const char *text, size_t length, struct config_field fie while (isspace((unsigned char)*start)) start++; if (*start == '[') ends[section] = pos; int index = config_field_line(line, §ion, fields, value); - if (index >= 0) { strcpy(fields[index].value, value); fields[index].present = 1; } + if (index >= 0) { + strcpy(fields[index].value, value); fields[index].present = 1; + if (index == CFG_REALITY_IDS) { + int count; + if (config_ini_hex_list(value, NULL, REALITY_SHORT_ID_SIZE, REALITY_MAX_SHORT_IDS, &count) || !count) + fields[index].invalid = 1; + } else if (index == CFG_NODE_ID) { + uint64_t id; + if (config_node_id(value, &id)) fields[index].invalid = 1; + } else if (index != CFG_REALITY_ENABLED && config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) fields[index].invalid = 1; + } pos = end; } ends[section] = length; @@ -356,6 +369,7 @@ int config_ensure_keys_and_node_id(const char *filename) { fields[CFG_NODE_ID].update = 1; snprintf(fields[CFG_NODE_ID].value, sizeof(fields[CFG_NODE_ID].value), "%016llx", (unsigned long long)node_id); } + for (int i = CFG_PRIVATE; i <= CFG_NODE_ID; i++) if (fields[i].invalid) fields[i].update = 1; int reality_enabled = atoi(fields[CFG_REALITY_ENABLED].value) != 0; if (reality_enabled) { if (config_ini_hex(fields[CFG_REALITY_PRIVATE].value, reality_private, sizeof(reality_private))) { @@ -378,6 +392,8 @@ int config_ensure_keys_and_node_id(const char *filename) { } fields[CFG_REALITY_IDS].update = 1; } + if (fields[CFG_REALITY_PRIVATE].invalid) fields[CFG_REALITY_PRIVATE].update = 1; + if (fields[CFG_REALITY_IDS].invalid) fields[CFG_REALITY_IDS].update = 1; } for (int i = 0; i < CFG_FIELD_COUNT; i++) { if (!fields[i].update) continue; diff --git a/src/config_updater.h b/src/config_updater.h index 10263ef9..30409a56 100644 --- a/src/config_updater.h +++ b/src/config_updater.h @@ -5,7 +5,9 @@ * или отсутствующем приватном ключе создаётся новая идентичность. HEX сравнивается по байтам. * REALITY: недостающий/ошибочный private_key генерируется; пустой/ошибочный short_ids * заменяется четырьмя новыми ID. Корректные параметры и выключенный REALITY сохраняются. - * Исправный файл остаётся побайтно неизменным. При правке сохраняются посторонний текст, + * Ошибочные ранние дубли этих полей заменяются выбранным последним значением, даже если оно корректно: + * штатный парсер проверяет каждое вхождение. Файл без таких ошибок остаётся побайтно неизменным. + * При правке сохраняются посторонний текст, * комментарии и существующие окончания строк; обновляются все дубли исправляемого поля. * Новый файл создаётся только при ENOENT. Встроенный NUL и строки >1022 байтов до LF запрещены. */ #ifndef CONFIG_UPDATER_H diff --git a/src/config_updater_doc.md b/src/config_updater_doc.md index e3711681..647b216f 100644 --- a/src/config_updater_doc.md +++ b/src/config_updater_doc.md @@ -27,7 +27,10 @@ HEX проверяется посимвольно до декодировани ## Изменение текста -Исправный файл остаётся побайтно неизменным. Если поле требует исправления, +Синтаксически ошибочный ранний дубль поля идентичности или включённого REALITY также исправляется +по выбранному последнему значению: штатный парсер проверяет каждое вхождение. +Корректная итоговая идентичность/REALITY не меняется и не генерируется заново только из-за раннего дубля. +Файл без таких ошибок остаётся побайтно неизменным. Если поле требует исправления, обновляются все его вхождения в соответствующих секциях. Недостающие поля добавляются в конец последней подходящей секции; отсутствующая секция создаётся в конце файла. @@ -37,8 +40,9 @@ HEX проверяется посимвольно до декодировани после последней строки без LF. Новый или пустой файл получает [global] с тремя полями идентичности. Перед записью итоговый текст проверяется строгими декодерами и штатным парсером. -Это проверка полей идентичности и необходимых параметров REALITY; парсер продолжает -логировать и пропускать некоторые ошибки прочих опций. +Штатный парсер отклоняет ошибки обработчиков известных опций; при таком отказе исходный файл сохраняется. +Отсутствие необходимости правки идентичности/REALITY не означает проверки остальных настроек: +вызывающая сторона всё равно загружает файл через parse_config. ## Запись и ошибки From 99d52afd3fd8a732e0085c32bf5f60bf1116761d Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:15:06 +0100 Subject: [PATCH 08/45] Validate global scalar options before narrowing or timer conversion --- src/config_parser.c | 195 +++++++++++++++++--------------------------- 1 file changed, 76 insertions(+), 119 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 450bc156..d3f93833 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -73,6 +73,24 @@ static int parse_unsigned(const char* text, int base, uint64_t maximum, uint64_t *out = value; return 0; } +/* Полное десятичное int без усечения и переполнения; границы задаёт владелец поля. */ +static int parse_integer(const char* text, int minimum, int maximum, int* out) { + char* end; + errno = 0; + long value = strtol(text, &end, 10); + if ((!isdigit((unsigned char)*text) && *text != '-') || end == text || *end || errno || value < minimum || value > maximum) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid integer '%s', expected %d..%d", text, minimum, maximum); return -1; + } + *out = (int)value; return 0; +} + +/* Единые булевы значения INI: 0/1, no/yes, false/true; -1 означает ошибку. */ +static int parse_boolean(const char* text) { + if (!strcmp(text, "1") || !strcasecmp(text, "yes") || !strcasecmp(text, "true")) return 1; + if (!strcmp(text, "0") || !strcasecmp(text, "no") || !strcasecmp(text, "false")) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid boolean '%s', expected yes/no, true/false or 1/0", text); return -1; +} + /* Версия содержит ровно три десятичных октета, без хвоста; выход применяется целиком. */ static int parse_version(const char* value, uint8_t out[3]) { char text[12]; @@ -342,136 +360,75 @@ static struct CFG_SERVER* find_server_by_name(struct CFG_SERVER *servers, const } static int parse_global(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { - if (strcmp(key, "my_node_name") == 0) { - size_t nlen = strlen(value); - if (nlen >= sizeof(global->name)) { - DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: my_node_name too long (%zu chars), truncating to %zu", - filename, line_num, nlen, sizeof(global->name) - 1); - } - strncpy(global->name, value, sizeof(global->name) - 1); - global->name[sizeof(global->name) - 1] = '\0'; - return 0; + if (!strcmp(key, "my_node_name")) { + if (strlen(value) >= sizeof(global->name)) + DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: my_node_name truncated to %zu bytes", filename, line_num, sizeof(global->name) - 1); + snprintf(global->name, sizeof(global->name), "%s", value); return 0; } - if (strcmp(key, "my_private_key") == 0) { - return assign_string(global->my_private_key_hex, MAX_KEY_LEN, value); + struct { const char* name; char* field; size_t size; } strings[] = { + {"my_private_key", global->my_private_key_hex, sizeof(global->my_private_key_hex)}, + {"my_public_key", global->my_public_key_hex, sizeof(global->my_public_key_hex)}, + {"tun_ifname", global->tun_ifname, sizeof(global->tun_ifname)}, + {"debug_level", global->debug_level, sizeof(global->debug_level)}, + {"log_file", global->log_file, sizeof(global->log_file)}, + {"db_path", global->db_path, sizeof(global->db_path)} + }; + for (size_t i = 0; i < sizeof(strings) / sizeof(*strings); i++) + if (!strcmp(key, strings[i].name)) return assign_string(strings[i].field, strings[i].size, value); + struct { const char* name; int* field; int minimum, maximum; } numbers[] = { + {"mtu", &global->mtu, 0, UINT16_MAX}, + {"keepalive_timeout", &global->keepalive_timeout, 0, INT_MAX / 10}, + {"keepalive_interval", &global->keepalive_interval, 0, UINT16_MAX}, + {"bbr_max_cwnd", &global->bbr_max_cwnd, INFLIGHT_LIM_MIN, INT_MAX} + }; + for (size_t i = 0; i < sizeof(numbers) / sizeof(*numbers); i++) + if (!strcmp(key, numbers[i].name)) return parse_integer(value, numbers[i].minimum, numbers[i].maximum, numbers[i].field); + struct { const char* name; int* field; } booleans[] = { + {"tun_enabled", &global->tun_enabled}, {"keepalive_adaptive", &global->keepalive_adaptive}, + {"auto_socket_skip_no_default_route", &global->auto_socket_skip_no_default_route}, + {"db_sync_enabled", &global->db_sync_enabled}, {"chatserver_enabled", &global->chatserver_enabled}, + {"enable_timestamp", &global->enable_timestamp}, {"enable_function_names", &global->enable_function_names}, + {"enable_file_lines", &global->enable_file_lines}, {"enable_colors", &global->enable_colors}, + {"tun_test_mode", &global->tun_test_mode} + }; + for (size_t i = 0; i < sizeof(booleans) / sizeof(*booleans); i++) { + if (strcmp(key, booleans[i].name)) continue; + int parsed = parse_boolean(value); + if (parsed < 0) return -1; + *booleans[i].field = parsed; return 0; } - if (strcmp(key, "my_public_key") == 0) { - return assign_string(global->my_public_key_hex, MAX_KEY_LEN, value); + if (!strcmp(key, "my_node_id")) return parse_unsigned(value, 16, UINT64_MAX, &global->my_node_id); + if (!strcmp(key, "db_sync_ttl")) { + uint64_t parsed; + if (parse_unsigned(value, 10, UINT32_MAX, &parsed)) return -1; + global->db_sync_ttl = (uint32_t)parsed; return 0; } - if (strcmp(key, "my_node_id") == 0) { - global->my_node_id = strtoull(value, NULL, 16); - return 0; + if (!strcmp(key, "client_type")) { + int parsed; + if (parse_integer(value, 0, 2, &parsed)) return -1; + global->client_type = (uint8_t)parsed; return 0; } - if (strcmp(key, "client_type") == 0) { - global->client_type = (uint8_t)atoi(value); - if (global->client_type > 2) global->client_type = 0; - return 0; + if (!strcmp(key, "tun_ip")) { + uint8_t prefix; + return parse_ip_with_netmask(value, &global->tun_ip, &prefix); } - if (strcmp(key, "tun_enabled") == 0) { - global->tun_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; - } - if (strcmp(key, "tun_ifname") == 0) { - snprintf(global->tun_ifname, sizeof(global->tun_ifname), "%s", value); - return 0; - } - if (strcmp(key, "tun_ip") == 0) { - uint8_t netmask; - return parse_ip_with_netmask(value, &global->tun_ip, &netmask); - } - if (strcmp(key, "mtu") == 0) { - global->mtu = atoi(value); - return 0; - } - if (strcmp(key, "keepalive_timeout") == 0) { - global->keepalive_timeout = atoi(value); - return 0; - } - if (strcmp(key, "keepalive_interval") == 0) { - global->keepalive_interval = atoi(value); - return 0; - } - if (strcmp(key, "keepalive_adaptive") == 0) { - global->keepalive_adaptive = atoi(value); - return 0; - } - if (strcmp(key, "bbr_max_cwnd") == 0) { - char* end; - errno = 0; - long parsed = strtol(value, &end, 10); - if (errno || end == value || *end || parsed < INFLIGHT_LIM_MIN || parsed > INT_MAX) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid bbr_max_cwnd '%s'; expected %u..%d bytes", - filename, line_num, value, INFLIGHT_LIM_MIN, INT_MAX); - return -1; - } - global->bbr_max_cwnd = (int)parsed; - return 0; - } - if (strcmp(key, "auto_sockets") == 0) { - if (strcasecmp(value, "android") == 0) global->auto_sockets = CFG_AUTO_SOCKETS_ANDROID; - else if (strcasecmp(value, "yes") == 0 || strcmp(value, "1") == 0) global->auto_sockets = CFG_AUTO_SOCKETS_SYSTEM; - else global->auto_sockets = CFG_AUTO_SOCKETS_OFF; - return 0; - } - if (strcmp(key, "auto_socket_skip_no_default_route") == 0) { - global->auto_socket_skip_no_default_route = strcasecmp(value, "yes") == 0 || strcmp(value, "1") == 0; - return 0; - } - if (strcmp(key, "auto_socket_protocols") == 0) { - if (strcasecmp(value, "tcp") == 0) global->auto_socket_protocols = CFG_AUTO_SOCKET_TCP; - else if (strcasecmp(value, "udp") == 0) global->auto_socket_protocols = CFG_AUTO_SOCKET_UDP; - else if (strcasecmp(value, "tcp+udp") == 0) global->auto_socket_protocols = CFG_AUTO_SOCKET_BOTH; + if (!strcmp(key, "auto_sockets")) { + if (!strcasecmp(value, "android")) global->auto_sockets = CFG_AUTO_SOCKETS_ANDROID; else { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: invalid auto_socket_protocols='%s'; expected tcp, udp or tcp+udp", - filename, line_num, value); - return -1; + int parsed = parse_boolean(value); + if (parsed < 0) return -1; + global->auto_sockets = parsed ? CFG_AUTO_SOCKETS_SYSTEM : CFG_AUTO_SOCKETS_OFF; } return 0; } - if (strcmp(key, "debug_level") == 0) { - return assign_string(global->debug_level, sizeof(global->debug_level), value); - } - if (strcmp(key, "log_file") == 0) { - return assign_string(global->log_file, sizeof(global->log_file), value); - } - if (strcmp(key, "db_path") == 0) { - return assign_string(global->db_path, sizeof(global->db_path), value); - } - if (strcmp(key, "db_sync_enabled") == 0) { - global->db_sync_enabled = atoi(value); + if (!strcmp(key, "auto_socket_protocols")) { + if (!strcasecmp(value, "tcp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_TCP; + else if (!strcasecmp(value, "udp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_UDP; + else if (!strcasecmp(value, "tcp+udp")) global->auto_socket_protocols = CFG_AUTO_SOCKET_BOTH; + else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: expected auto_socket_protocols=tcp, udp or tcp+udp", filename, line_num); return -1; } return 0; } - if (strcmp(key, "chatserver_enabled") == 0) { - global->chatserver_enabled = atoi(value); - return 0; - } - if (strcmp(key, "db_sync_ttl") == 0) { - global->db_sync_ttl = (uint32_t)atoi(value); - return 0; - } - /* debug_categories key is deprecated - use [debug] section instead */ - if (strcmp(key, "enable_timestamp") == 0) { - global->enable_timestamp = atoi(value); - return 0; - } - if (strcmp(key, "enable_function_names") == 0) { - global->enable_function_names = atoi(value); - return 0; - } - if (strcmp(key, "enable_file_lines") == 0) { - global->enable_file_lines = atoi(value); - return 0; - } - if (strcmp(key, "enable_colors") == 0) { - global->enable_colors = atoi(value); - return 0; - } - if (strcmp(key, "tun_test_mode") == 0) { - global->tun_test_mode = atoi(value); - return 0; - } - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown global option '%s'. Valid: my_node_name, my_private_key, my_public_key, my_node_id, client_type, tun_ifname, tun_ip, mtu, keepalive_timeout, keepalive_interval, keepalive_adaptive, bbr_max_cwnd, debug_level, log_file, db_path, enable_timestamp, enable_function_names, enable_file_lines, enable_colors, tun_test_mode", filename, line_num, key); - return -1; + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown global option '%s'", filename, line_num, key); return -1; } static int parse_control(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { From e4852c82b432e2af065f5805d567af588fae205f Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:15:47 +0100 Subject: [PATCH 09/45] Check service scalars and proxy ports before storing configuration --- src/config_parser.c | 87 ++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index d3f93833..ef77f648 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -456,40 +456,34 @@ static int parse_control(const char *key, const char *value, struct global_confi static int parse_tcp_proxy_client(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { if (strcmp(key, "enabled") == 0) { - global->tcp_proxy_client_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + global->tcp_proxy_client_enabled = enabled; return 0; } if (strcmp(key, "tun_name") == 0) { - strncpy(global->tcp_proxy_client_tun_name, value, sizeof(global->tcp_proxy_client_tun_name) - 1); - return 0; + return assign_string(global->tcp_proxy_client_tun_name, sizeof(global->tcp_proxy_client_tun_name), value); } if (strcmp(key, "tun_ip") == 0) { - strncpy(global->tcp_proxy_client_tun_ip, value, sizeof(global->tcp_proxy_client_tun_ip) - 1); - return 0; + return assign_string(global->tcp_proxy_client_tun_ip, sizeof(global->tcp_proxy_client_tun_ip), value); } if (strcmp(key, "mtu") == 0) { - global->tcp_proxy_client_mtu = atoi(value); - return 0; + return parse_integer(value, 0, UINT16_MAX, &global->tcp_proxy_client_mtu); } if (strcmp(key, "via_node") == 0) { - global->tcp_proxy_client_via_node_id = strtoull(value, NULL, 16); - return 0; + return parse_unsigned(value, 16, UINT64_MAX, &global->tcp_proxy_client_via_node_id); } if (strcmp(key, "socks_enabled") == 0) { - global->tcp_proxy_client_socks_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + global->tcp_proxy_client_socks_enabled = enabled; return 0; } if (strcmp(key, "socks_addr") == 0) { - strncpy(global->tcp_proxy_client_socks_addr, value, sizeof(global->tcp_proxy_client_socks_addr) - 1); - return 0; + return assign_string(global->tcp_proxy_client_socks_addr, sizeof(global->tcp_proxy_client_socks_addr), value); } if (strcmp(key, "http_proxy_enabled") == 0) { - global->tcp_proxy_client_http_proxy_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + global->tcp_proxy_client_http_proxy_enabled = enabled; return 0; } if (strcmp(key, "http_proxy_addr") == 0) { - strncpy(global->tcp_proxy_client_http_proxy_addr, value, sizeof(global->tcp_proxy_client_http_proxy_addr) - 1); - return 0; + return assign_string(global->tcp_proxy_client_http_proxy_addr, sizeof(global->tcp_proxy_client_http_proxy_addr), value); } if (strcmp(key, "forward") == 0) { if (global->tcp_proxy_client_mapping_count >= MAX_TCP_PROXY_CLIENT_MAPPINGS) { @@ -497,8 +491,7 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo return -1; } char buf[256]; - strncpy(buf, value, sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; + if (assign_string(buf, sizeof(buf), value)) return -1; config_ini_trim(buf); char* local_port_str = strtok(buf, " "); @@ -508,8 +501,10 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward format: %s (expected: port -> ip:port)", value); return -1; } - int local_port = atoi(local_port_str); - if (local_port <= 0 || local_port > 65535) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward local port: %s", local_port_str); return -1; } + uint64_t local_port; + if (parse_unsigned(local_port_str, 10, UINT16_MAX, &local_port) || !local_port) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid tcp_proxy_client forward local port"); return -1; + } char* remote_ip_str = strtok(remote_str, ":"); char* remote_port_str = strtok(NULL, ""); @@ -517,8 +512,11 @@ static int parse_tcp_proxy_client(const char *key, const char *value, struct glo DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward remote: %s (expected ip:port)", remote_str); return -1; } - int remote_port = atoi(remote_port_str); - if (remote_port <= 0 || remote_port > 65535) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Invalid tcp_proxy_client forward remote port: %s", remote_port_str); return -1; } + uint64_t remote_port; + struct in_addr address; + if (parse_unsigned(remote_port_str, 10, UINT16_MAX, &remote_port) || !remote_port || inet_pton(AF_INET, remote_ip_str, &address) != 1) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid tcp_proxy_client forward remote IPv4/port"); return -1; + } int idx = global->tcp_proxy_client_mapping_count; global->tcp_proxy_client_mappings[idx].local_port = (uint16_t)local_port; @@ -660,12 +658,10 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s return parse_address_and_port(value, &srv->ip); } if (strcmp(key, "so_mark") == 0) { - srv->so_mark = atoi(value); - return 0; + return parse_integer(value, INT_MIN, INT_MAX, &srv->so_mark); } if (strcmp(key, "fib") == 0) { - srv->fib = atoi(value); - return 0; + return parse_integer(value, -1, INT_MAX, &srv->fib); } if (strcmp(key, "netif") == 0) { srv->netif_index = get_netif_index(value); @@ -686,12 +682,11 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s return 0; } if (strcmp(key, "mtu") == 0) { - srv->mtu = atoi(value); - return 0; + return parse_integer(value, 0, UINT16_MAX, &srv->mtu); } if (strcmp(key, "only_local") == 0) { - srv->only_local = atoi(value) ? 1 : 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + srv->only_local = (uint8_t)enabled; return 0; } if (strcmp(key, "transport") == 0) { if (strcmp(value, "tcp") == 0) srv->transport = 1; @@ -700,12 +695,12 @@ static int parse_server(const char *key, const char *value, struct CFG_SERVER *s return 0; } if (strcmp(key, "reality") == 0) { - srv->reality_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + srv->reality_enabled = (uint8_t)enabled; return 0; } if (strcmp(key, "socks") == 0) { - srv->socks_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + srv->socks_enabled = (uint8_t)enabled; return 0; } DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown server option '%s'. Valid: addr, so_mark, fib, netif, type, mtu, only_local, transport, reality, socks", filename, line_num, key); return -1; @@ -744,13 +739,12 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c } if (strcmp(key, "keepalive") == 0) { - cli->keepalive = atoi(value); - return 0; + return parse_integer(value, 0, INT_MAX, &cli->keepalive); } if (strcmp(key, "reality") == 0) { - cli->reality_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + cli->reality_enabled = (uint8_t)enabled; return 0; } if (strcmp(key, "server_name") == 0) { return assign_string(cli->reality.server_name, sizeof(cli->reality.server_name), value); } if (strcmp(key, "short_id") == 0) { @@ -858,7 +852,10 @@ static int parse_network(const char *key, const char *value, struct CFG_NETWORK } static int parse_reality(const char *key, const char *value, struct reality_config *rc, const char *filename, int line_num) { - if (strcmp(key, "enabled") == 0) { rc->enabled = atoi(value) ? 1 : 0; return 0; } + if (strcmp(key, "enabled") == 0) { + int enabled = parse_boolean(value); if (enabled < 0) return -1; + rc->enabled = enabled; return 0; + } if (strcmp(key, "server_name") == 0) { return assign_string(rc->server_name, sizeof(rc->server_name), value); } if (strcmp(key, "dest") == 0) { return assign_string(rc->dest, sizeof(rc->dest), value); } if (strcmp(key, "short_id") == 0) { @@ -897,8 +894,8 @@ static int parse_reality(const char *key, const char *value, struct reality_conf if (strcmp(key, "version") == 0) { return parse_version(value, rc->version); } - if (strcmp(key, "time_window") == 0) { rc->time_window_sec = atoi(value); return 0; } - if (strcmp(key, "relay_idle_timeout") == 0) { rc->relay_idle_timeout_sec = atoi(value); return 0; } + if (strcmp(key, "time_window") == 0) return parse_integer(value, 0, 3600, &rc->time_window_sec); + if (strcmp(key, "relay_idle_timeout") == 0) return parse_integer(value, 0, INT_MAX / 10000, &rc->relay_idle_timeout_sec); if (strcmp(key, "fingerprint") == 0) { if (strcmp(value, "openssl") == 0) { rc->fingerprint = REALITY_FP_OPENSSL; return 0; } DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown reality fingerprint '%s' (valid: openssl)", filename, line_num, value); @@ -911,8 +908,8 @@ static int parse_reality(const char *key, const char *value, struct reality_conf // Разбор [socks] секции: прокси для исходящих ETCP-линков. static int parse_socks(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { if (strcmp(key, "enabled") == 0) { - global->socks_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; - return 0; + int enabled = parse_boolean(value); if (enabled < 0) return -1; + global->socks_enabled = enabled; return 0; } if (strcmp(key, "addr") == 0) { const char *colon = strrchr(value, ':'); From eff0446f8d94f27741eef4fb6adf18f4410b3bd9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:24:07 +0100 Subject: [PATCH 10/45] Align boolean settings and timer bounds across parser and updater --- src/config_parser.c | 27 ++++++++++++++++----------- src/config_updater.c | 13 +++++++++++-- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index ef77f648..8a44c65a 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -1134,8 +1134,10 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha break; case SECTION_CONTROL: option_result = parse_control(key, value, &cfg->global, filename, line_num); break; case SECTION_ALLOWED_KEYS: - if (strcmp(key, "allow_all") == 0) - cfg->global.allowed_keys_allow_all = !strcasecmp(value, "yes") || !strcmp(value, "1") || !strcasecmp(value, "true"); + if (strcmp(key, "allow_all") == 0) { + int enabled = parse_boolean(value); if (enabled < 0) goto error; + cfg->global.allowed_keys_allow_all = enabled; + } else if (strcmp(key, "key") == 0) { struct CFG_ALLOWED_KEY* nk = u_calloc(1, sizeof(*nk)); if (!nk) goto error; @@ -1150,18 +1152,18 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha case SECTION_TCP_PROXY_CLIENT: option_result = parse_tcp_proxy_client(key, value, &cfg->global, filename, line_num); break; case SECTION_TCP_PROXY_SERVER: if (strcmp(key, "tcp_recv_buf") == 0) { - cfg->global.tcp_recv_buf = atoi(value); + option_result = parse_integer(value, 0, INT_MAX, &cfg->global.tcp_recv_buf); } else if (strcmp(key, "fib") == 0) { - cfg->global.tcp_proxy_server_fib = atoi(value); + option_result = parse_integer(value, 0, INT_MAX, &cfg->global.tcp_proxy_server_fib); } else if (strcmp(key, "so_mark") == 0) { - cfg->global.tcp_proxy_server_so_mark = atoi(value); + option_result = parse_integer(value, INT_MIN, INT_MAX, &cfg->global.tcp_proxy_server_so_mark); } else if (strcmp(key, "netif") == 0) { cfg->global.tcp_proxy_server_netif_index = get_netif_index(value); } else if (strcmp(key, "bind_ip") == 0) { strncpy(cfg->global.tcp_proxy_server_bind_ip, value, sizeof(cfg->global.tcp_proxy_server_bind_ip) - 1); cfg->global.tcp_proxy_server_bind_ip[sizeof(cfg->global.tcp_proxy_server_bind_ip) - 1] = '\0'; } else if (strcmp(key, "connect_timeout") == 0) { - cfg->global.tcp_proxy_server_connect_timeout_ms = atoi(value); + option_result = parse_integer(value, 0, INT_MAX / 10, &cfg->global.tcp_proxy_server_connect_timeout_ms); } else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown tcp_proxy_server option '%s'. Valid: tcp_recv_buf, fib, so_mark, netif, bind_ip, connect_timeout", filename, line_num, key); goto error; @@ -1169,15 +1171,17 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha break; case SECTION_NETWORK: option_result = 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") || !strcmp(value, "1") || !strcasecmp(value, "true"); + if (strcmp(key, "enabled") == 0) { + int enabled = parse_boolean(value); if (enabled < 0) goto error; + cfg->global.ntp_enabled = enabled; + } else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER* ns = u_calloc(1, sizeof(*ns)); if (!ns) goto error; if (assign_string(ns->name, sizeof(ns->name), value) || !value[0]) { u_free(ns); goto error; } 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); + option_result = parse_integer(value, 0, INT_MAX / 10000, &cfg->global.ntp_resync_interval); 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); goto error; } break; @@ -1199,8 +1203,9 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha case SECTION_SOCKS: option_result = parse_socks(key, value, &cfg->global, filename, line_num); break; case SECTION_STUN: if (parse_stun(key, value, &cfg->global, filename, line_num) < 0) goto error; break; case SECTION_LOG_UDP: - if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1); - else if (strcmp(key, "port") == 0) cfg->global.log_udp_port = atoi(value); + if (strcmp(key, "ip") == 0) option_result = assign_string(cfg->global.log_udp_ip, sizeof(cfg->global.log_udp_ip), value); + else if (strcmp(key, "port") == 0) option_result = parse_integer(value, 0, UINT16_MAX, &cfg->global.log_udp_port); + else { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: unknown log_udp option '%s'", filename, line_num, key); goto error; } break; default: DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: Key outside section: %s", filename, line_num, key); break; } diff --git a/src/config_updater.c b/src/config_updater.c index 71a26520..4e026ff1 100644 --- a/src/config_updater.c +++ b/src/config_updater.c @@ -89,7 +89,10 @@ static void config_scan(const char *text, size_t length, struct config_field fie } else if (index == CFG_NODE_ID) { uint64_t id; if (config_node_id(value, &id)) fields[index].invalid = 1; - } else if (index != CFG_REALITY_ENABLED && config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) fields[index].invalid = 1; + } else if (index == CFG_REALITY_ENABLED) { + if (strcmp(value, "0") && strcmp(value, "1") && strcasecmp(value, "no") && strcasecmp(value, "yes") && + strcasecmp(value, "false") && strcasecmp(value, "true")) fields[index].invalid = 1; + } else if (config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) fields[index].invalid = 1; } pos = end; } @@ -370,7 +373,13 @@ int config_ensure_keys_and_node_id(const char *filename) { snprintf(fields[CFG_NODE_ID].value, sizeof(fields[CFG_NODE_ID].value), "%016llx", (unsigned long long)node_id); } for (int i = CFG_PRIVATE; i <= CFG_NODE_ID; i++) if (fields[i].invalid) fields[i].update = 1; - int reality_enabled = atoi(fields[CFG_REALITY_ENABLED].value) != 0; + const char* enabled = fields[CFG_REALITY_ENABLED].value; + int reality_enabled = !strcmp(enabled, "1") || !strcasecmp(enabled, "yes") || !strcasecmp(enabled, "true"); + if (fields[CFG_REALITY_ENABLED].present && !reality_enabled && + strcmp(enabled, "0") && strcasecmp(enabled, "no") && strcasecmp(enabled, "false")) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: invalid REALITY enabled value", filename); goto done; + } + if (fields[CFG_REALITY_ENABLED].invalid) fields[CFG_REALITY_ENABLED].update = 1; if (reality_enabled) { if (config_ini_hex(fields[CFG_REALITY_PRIVATE].value, reality_private, sizeof(reality_private))) { uint8_t public_part[REALITY_AUTH_KEY_SIZE]; From 1041849030cb11572a87da527c00636804259f2c Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:24:57 +0100 Subject: [PATCH 11/45] Reject oversized service strings and cover scalar bounds --- src/config_parser.c | 23 ++++++----------------- tests/test_config_parser.c | 7 ++++++- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 8a44c65a..70eaace7 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -790,14 +790,10 @@ static int parse_size_with_suffix(const char *value, uint64_t *out_bytes) { 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; + return assign_string(global->chatserver_join_password, sizeof(global->chatserver_join_password), value); } 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; + return assign_string(global->chatserver_allowed_groups, sizeof(global->chatserver_allowed_groups), value); } if (strcmp(key, "storage_total_size") == 0) { uint64_t sz; @@ -818,19 +814,13 @@ static int parse_chatserver(const char *key, const char *value, struct global_co return 0; } if (strcmp(key, "headless_control_bind") == 0) { - strncpy(global->headless_control_bind, value, sizeof(global->headless_control_bind) - 1); - global->headless_control_bind[sizeof(global->headless_control_bind) - 1] = '\0'; - return 0; + return assign_string(global->headless_control_bind, sizeof(global->headless_control_bind), value); } if (strcmp(key, "call_audio_bind") == 0) { - strncpy(global->call_audio_bind, value, sizeof(global->call_audio_bind) - 1); - global->call_audio_bind[sizeof(global->call_audio_bind) - 1] = '\0'; - return 0; + return assign_string(global->call_audio_bind, sizeof(global->call_audio_bind), value); } if (strcmp(key, "radio_audio_bind") == 0) { - strncpy(global->radio_audio_bind, value, sizeof(global->radio_audio_bind) - 1); - global->radio_audio_bind[sizeof(global->radio_audio_bind) - 1] = '\0'; - return 0; + return assign_string(global->radio_audio_bind, sizeof(global->radio_audio_bind), value); } return -1; } @@ -1160,8 +1150,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha } else if (strcmp(key, "netif") == 0) { cfg->global.tcp_proxy_server_netif_index = get_netif_index(value); } else if (strcmp(key, "bind_ip") == 0) { - strncpy(cfg->global.tcp_proxy_server_bind_ip, value, sizeof(cfg->global.tcp_proxy_server_bind_ip) - 1); - cfg->global.tcp_proxy_server_bind_ip[sizeof(cfg->global.tcp_proxy_server_bind_ip) - 1] = '\0'; + option_result = assign_string(cfg->global.tcp_proxy_server_bind_ip, sizeof(cfg->global.tcp_proxy_server_bind_ip), value); } else if (strcmp(key, "connect_timeout") == 0) { option_result = parse_integer(value, 0, INT_MAX / 10, &cfg->global.tcp_proxy_server_connect_timeout_ms); } else { diff --git a/tests/test_config_parser.c b/tests/test_config_parser.c index b58183b4..b13dd7b4 100644 --- a/tests/test_config_parser.c +++ b/tests/test_config_parser.c @@ -63,7 +63,12 @@ static void test_rejections(void) { "[chat]\nstorage_total_size=18446744073709551616\n", "[chat]\nstorage_total_size=17179869184G\n", "[allowed_keys]\nkey=bad\n", "[network:n]\nid=100000000000000\n", "[network:n]\npubkey=1g\n", "[network:n]\nid=1\n", "[ntp]\nserver=\n", "[tcp_proxy_client]\nforward=0 -> 1.2.3.4:80\n", - "[server:s]\ninvalid\n" + "[server:s]\ninvalid\n", "[global]\nkeepalive_interval=65536\n", "[global]\nclient_type=256\n", + "[global]\nmtu=1280junk\n", "[global]\ndb_sync_ttl=-1\n", "[global]\nauto_sockets=maybe\n", + "[global]\nkeepalive_timeout=999999999999999999999999\n", "[reality]\ntime_window=3601\n", + "[reality]\nrelay_idle_timeout=214749\n", "[tcp_proxy_server]\nconnect_timeout=-1\n", + "[tcp_proxy_client]\nforward=80junk -> 127.0.0.1:80\n", "[tcp_proxy_client]\nforward=80 -> localhost:80\n", + "[ntp]\ninterval=214749\n", "[ntp]\nenabled=maybe\n", "[socks]\nenabled=maybe\n" }; char path[] = "/tmp/utun-parser-contract-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd); From 9a7091b595f4e4fe1a42f9ce49413d914fc3752c Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:26:10 +0100 Subject: [PATCH 12/45] Document strict parser ownership and validate provided peer keys --- src/config_parser.c | 6 ++++++ src/config_parser.h | 47 ++++++++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/config_parser.c b/src/config_parser.c index 70eaace7..9e210958 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -360,6 +360,9 @@ static struct CFG_SERVER* find_server_by_name(struct CFG_SERVER *servers, const } static int parse_global(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { + if ((!strcmp(key, "my_private_key") || !strcmp(key, "my_public_key")) && *value && config_ini_hex(value, NULL, SC_PRIVKEY_SIZE)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: %s requires 64 HEX characters", filename, line_num, key); return -1; + } if (!strcmp(key, "my_node_name")) { if (strlen(value) >= sizeof(global->name)) DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: my_node_name truncated to %zu bytes", filename, line_num, sizeof(global->name) - 1); @@ -735,6 +738,9 @@ static int parse_client(const char *key, const char *value, struct CFG_CLIENT *c } if (strcmp(key, "peer_public_key") == 0) { + if (*value && config_ini_hex(value, NULL, SC_PUBKEY_SIZE)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: peer_public_key requires 64 HEX characters", filename, line_num); return -1; + } return assign_string(cli->peer_public_key_hex, MAX_KEY_LEN, value); } diff --git a/src/config_parser.h b/src/config_parser.h index cac434fd..524eb9b9 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -3,9 +3,13 @@ * Успех возвращает выделенную utun_config со списками; освобождение — только free_config. * Файл и буфер проходят один разбор. Пробелы вокруг секций, ключей и значений удаляются; * имена секций нечувствительны к регистру, имена ключей чувствительны. Комментарий — #. - * HEX проверяется целиком; ошибочный список REALITY short_ids не применяется частично. + * Ошибка обработчика известной опции отклоняет весь конфиг с освобождением частичной модели. + * HEX проверяется целиком; network требует оба ключа. Неизвестные секции игнорируются с WARN; + * [gui] обрабатывает только utun_enabled. Числа проверяются до сужения, bool — yes/no/true/false/1/0. + * Повторные значения применяются по порядку; ошибочный ранний дубль также отклоняется. * Встроенный NUL и строки >1022 байтов до LF запрещены (CR входит в этот предел). - * Парсер заполняет модель, запуск сетевых ресурсов выполняет utun_instance. */ + * Парсер не открывает сетевые сокеты, но синхронно разрешает addr/control через getaddrinfo; + * на Windows вызывающий заранее инициализирует socket subsystem. Сервисы запускает utun_instance. */ #ifndef CONFIG_PARSER_H #define CONFIG_PARSER_H @@ -26,7 +30,7 @@ extern "C" { #define MAX_ADDR_LEN 64 struct IP { - sa_family_t family; + sa_family_t family; // AF_INET/AF_INET6, 0 — адрес не задан; байты addr в сетевом порядке union { struct in_addr v4; struct in6_addr v6; @@ -55,32 +59,32 @@ struct IP { #define CFG_IPV6_MODE_PERMANENT 2 // permanent IPv6 from interface struct CFG_SERVER { - struct CFG_SERVER* next; - char name[MAX_CONN_NAME_LEN]; + struct CFG_SERVER* next; // собственная цепочка конфигурации; список в обратном порядке секций + char name[MAX_CONN_NAME_LEN]; // имя [server:name], до 63 байт struct sockaddr_storage ip; // ip:port uint32_t netif_index;// if_nameindex, 0 - no interface specified int so_mark; int fib; // FreeBSD FIB (routing table), -1 = don't set - uint8_t type; // public/nat/private + uint8_t type; // CFG_SERVER_TYPE_*; 0 если явно не задан uint8_t transport; // 0=udp (default), 1=tcp uint8_t reality_enabled; // 1 = TCP-порт с REALITY-камуфляжем (только transport=1) uint8_t socks_enabled; // 1 = UDP-сокет туннелировать через SOCKS5 (только transport=0) uint8_t ipv6_mode; // CFG_IPV6_MODE_* - int mtu; + int mtu; // 0 — не задан, при явной опции 0..65535; ресурс проверяет применимость uint8_t only_local; // 1 = only local connections, no forwarding }; struct CFG_CLIENT_LINK { struct CFG_CLIENT_LINK *next; // Next link in linked list - struct CFG_SERVER* local_srv; - char server_name[MAX_CONN_NAME_LEN]; // Name of local server for this link + struct CFG_SERVER* local_srv; // заимствован у того же utun_config; NULL — remote-only + char server_name[MAX_CONN_NAME_LEN]; // копия имени local_srv; пуста для remote-only struct sockaddr_storage remote_addr; // ip:port }; struct CFG_CLIENT { char name[MAX_CONN_NAME_LEN]; char peer_public_key_hex[MAX_KEY_LEN]; - int keepalive; + int keepalive; // необязательная клиентская настройка; 0 по умолчанию, неотрицательный int struct CFG_CLIENT_LINK *links; // Linked list of links struct CFG_CLIENT *next; // Next client in linked list uint8_t reality_enabled; // 1 = TCP-линк с REALITY-камуфляжем @@ -97,8 +101,8 @@ struct CFG_NETWORK { struct CFG_ROUTE_ENTRY { struct CFG_ROUTE_ENTRY* next; - struct IP ip; - uint8_t netmask; + struct IP ip; // исходный адрес подсети (host-биты парсер не нормализует) + uint8_t netmask; // префикс 0..32/0..128; без / по умолчанию 32/128 по семейству }; struct CFG_FIREWALL_RULE { @@ -117,15 +121,15 @@ struct CFG_ALLOWED_KEY { }; struct CFG_NTP_SERVER { - char name[256]; + char name[256]; // NUL-terminated hostname/IP; парсер не разрешает его, список идёт в обратном порядке struct CFG_NTP_SERVER *next; }; /* STUN-серверы обходятся в порядке конфигурации; DNS выполняет модуль stun_nat. */ struct CFG_STUN_SERVER { struct CFG_STUN_SERVER* next; - char host[256]; - uint16_t port; + char host[256]; // IPv4/имя A-сервера; DNS выполняет stun_nat + uint16_t port; // 1..65535, по умолчанию 3478 }; #define MAX_TCP_PROXY_CLIENT_MAPPINGS 32 @@ -273,16 +277,23 @@ struct utun_config { struct CFG_NETWORK* networks; }; -/* Непустой путь; результат освобождать free_config. NULL при ошибке открытия, чтения, - * закрытия, памяти или обязательной проверки формата, включая ошибки [global]. Часть опций других секций только - * логируется и пропускается; ненулевой результат не гарантирует корректность всех полей. */ +/* Непустой путь; результат освобождать free_config. NULL при ошибке открытия/чтения/закрытия, + * памяти, формата или обработчика известной опции. Значения по умолчанию и полноту настройки + * для конкретного сервиса дополнительно проверяет его start; ненулевой результат не гарантирует запуск. + * Файл не изменяется, ключи не генерируются: для этого отдельно вызвать config_ensure_keys_and_node_id. */ struct utun_config* parse_config(const char *filename); /* buf — len байтов без завершающего NUL (NULL допустим при len=0), буфер не меняется. * filename — источник для диагностики, NULL означает . Возврат как у parse_config. */ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const char *filename); +// Освобождает модель и все собственные списки/массивы; заимствованные ссылки становятся недействительны; NULL допустим. void free_config(struct utun_config *config); +// Пишет диагностический снимок в config/INFO; private/signing keys не выводятся; NULL — no-op. void print_config(const struct utun_config *config); +// Печатает в config/INFO и stderr bootstrap utun:// v3 с channel_id/join_key=0 по адресам servers (до 32). +// Чат должен быть включён; используются конфигурационные адреса, не фактический bind/NAT. NULL — no-op. void print_chatserver_invite_link(const struct utun_config *config, const struct CFG_SERVER *servers); +// Устаревшее добавление [global] с двумя 32-byte HEX ключами в конец файла; 0/-1 с логом. +// Не атомарно, частичная запись возможна. Для согласованных ключей/node_id использовать config_updater. int update_config_keys(const char *filename, const char *priv_key, const char *pub_key); /* Узел (по pubkey, 32 байта) присутствует в списке [client] конфига? 1=да, 0=нет. */ From 9888820146b80102f25fb3e0cb15cbca7c263fc8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:28:42 +0100 Subject: [PATCH 13/45] Describe parser validation, list order and service defaults --- src/config_parser_doc.md | 179 +++++++++++++++++++-------------------- 1 file changed, 86 insertions(+), 93 deletions(-) diff --git a/src/config_parser_doc.md b/src/config_parser_doc.md index 968c9f6d..e173c069 100644 --- a/src/config_parser_doc.md +++ b/src/config_parser_doc.md @@ -1,109 +1,102 @@ -# Config Parser (config_parser.c/h) +# INI-конфигурация ядра -## 1. Назначение +`config_parser.c/h` загружает собственную модель `utun_config` с настройками и списками. +`parse_config(path)` читает файл тем же разборщиком, что `parse_config_from_buf`. +Парсер не запускает ядро и не генерирует ключи; для файлового запуска отдельно используется config_updater. -INI-style парсер конфигурационного файла uTun. Читает текстовый конфиг, разбирает его на структуры и возвращает заполненный `struct utun_config` — корневое представление всех настроек. +## Вход, владение и ошибки -Поддерживает два режима входа: -- `parse_config(filename)` — чтение из файла -- `parse_config_from_buf(buf, len, filename)` — чтение из буфера (для chatgui/in-memory использования) +Файл открывается только для чтения. Ошибки открытия/чтения/закрытия дают NULL. +Буфер содержит ровно len байтов без завершающего NUL, NULL допустим при len=0. +filename буферного API используется только в диагностике; NULL заменяется на ``. +Вход не меняется. Результат и все списки освобождает `free_config`; NULL безопасен. +`CFG_CLIENT_LINK.local_srv` заимствован из servers того же конфига; server_name содержит копию имени. -Формат: стандартные `.ini`-секции `[section]`, строки `key=value`, комментарии `#`. Именованные секции через двоеточие: `[server:main]`, `[client:peer1]`, `[network:mynet]`. +Ошибка обработчика известной опции, памяти или key=value отклоняет всю модель. +Частичные списки освобождаются. Каждое вхождение проверяется: ошибочный ранний дубль +не маскируется поздним корректным. Повторные корректные скаляры применяются по порядку. +Config updater исправляет управляемые ключи/node_id и ошибочные ранние дубли перед загрузкой. -Файл и буфер проходят один разбор. Общие функции в `config_ini.h` удаляют пробелы -вокруг имён секций, ключей и значений. Имена секций нечувствительны к регистру, -имена ключей чувствительны. Встроенный NUL и физические строки длиннее 1022 байтов -до LF запрещены; CR учитывается в этом пределе. -Ошибки разбора опций `[nat]` отклоняют весь конфиг. Формат статического проброса: -`forward=proto:node_id_hex:IPv4:internal_port:external_port`; узел назначения обязателен, -оба порта — десятичные 1..65535. Динамический диапазон дополнительно проверяется при запуске NAT. +Ненулевой результат не гарантирует запуск: обязательность отсутствующих полей, +устройства, комбинации опций и доступность портов дополнительно проверяют владельцы ресурсов. +Адреса server/client/control разрешаются синхронно через getaddrinfo с выбором первого адреса. +На Windows вызывающий заранее инициализирует socket subsystem; сетевые сокеты парсер не открывает. -## 2. Как пользоваться +## Формат и списки -```c -struct utun_config *cfg = parse_config("/etc/utun/config.ini"); -if (!cfg) { /* ошибка — смотри логи DEBUG_CATEGORY_CONFIG */ } +Пробелы вокруг секций, ключей и значений удаляются. Секции нечувствительны к регистру, ключи чувствительны. +Комментарий # удаляется из значения; заголовок секции должен занимать полную строку. +Встроенный NUL и физические строки длиннее 1022 байтов до LF запрещены; CR входит в предел. +Неизвестные/нераспознанные секции и их ключи игнорируются с WARN. +Известные секции отклоняют неизвестные опции, кроме ограниченной обработки debug/gui. +В debug хранится до DEBUG_CATEGORY_COUNT строк; лишние игнорируются с WARN. +Категории/уровни хранятся в полях до 15 байтов, фактические имена проверяются debug API при применении. +GUI читает только utun_enabled, остальные ключи остаются frontend. -// Работа с конфигом... -const char *tun_name = cfg->global.tun_ifname; -struct CFG_SERVER *srv = cfg->servers; -while (srv) { - ip_str_t addr = sockaddr_storage_to_str(&srv->ip); - srv = srv->next; -} +Именованные server/client/network секции создают отдельные записи, дубли имён не объединяются. +Servers, clients, networks, маршруты, allowed_keys и NTP добавляются в голову списка; +порядок обхода обратный вводу. STUN сохраняет порядок. +В link=local_server:host:port локальный сервер должен быть объявлен выше. +Если имя не найдено, весь link рассматривается как remote-only host:port с local_srv=NULL. -// Освобождение -free_config(cfg); -``` +## Значения -**Нюансы:** -- `parse_config` возвращает `NULL` при ошибках открытия, чтения, закрытия, памяти и обязательных проверок формата. Некоторые ошибки отдельных опций только логируются и пропускаются; ненулевой результат не гарантирует корректность всех полей. -- Обработка длинных значений зависит от поля: часть строк обрезается, `assign_string` отклоняет значение без изменения поля. -- Ключи клиента `link` требуют, чтобы сервер (`[server:имя]`) был объявлен выше по файлу. Иначе ошибка. -- `tun_enabled` по умолчанию `0`. При явном включении и отсутствии имени интерфейса используется `tun0`. -- При `auto_sockets=yes` параметр `[global] auto_socket_protocols=tcp|udp|tcp+udp` выбирает протоколы автосокетов. - По умолчанию `tcp+udp`; неизвестное или пустое значение отклоняется. Ручные `[server]` используют свой `transport`. - В Android выбранные протоколы генерирует frontend в отдельные `[server]` с портами на интерфейс/семейство/протокол. -- IPv6-адреса в `[server:...] addr` поддерживают префиксы `temporary_ipv6:` и `permanent_ipv6:`. -- HEX декодируется после проверки каждого символа. Ошибочный список REALITY `short_ids` отклоняется целиком и оставляет `short_id_count=0`; ошибочный последний REALITY-ключ сбрасывает соответствующий `has_*_key`. +- Endpoint: IPv4/IPv6/hostname и десятичный порт 0..65535; IPv6 допускает скобки. + Порт 0 оставлен для автоматического bind, применимость проверяет ресурс. +- SOCKS: непустой host до 63 байтов, порт 1..65535. STUN: IPv4/A-hostname[:port], по умолчанию 3478; + при включении минимум две разные строки server, независимость IP проверяет stun_nat. +- Подсеть: IPv4/IPv6[/prefix], диапазон 0..32/0..128. Без / используется /32 или /128; + явный IPv6 /32 сохраняется. Host-биты не нормализуются. Control allow — только IPv4, включая /0. +- Числа проверяются целиком до сужения. Hex-ID допускает 0x, беззнаковые значения не принимают знак. + Bool: yes/no, true/false, 1/0. Keepalive interval помещается в uint16_t; таймеры проверяются до умножения. + NTP interval меньше 60 секунд приводится к 60, по умолчанию 3600. +- Storage: uint64_t байты либо один суффикс M/G (1024²/1024³), без переполнения, знака и хвоста MB. + MTU: 0..65535, дополнительная проверка у ресурса. +- HEX проверяется целиком. Network требует оба 32-byte ключа и ID до 56 бит. + Пустые необязательные ключи идентичности/пира остаются незаданными для последующей инициализации. +- REALITY short_ids проверяется всем списком; version — ровно три десятичных октета. + time_window=0..3600, relay_idle_timeout=0..INT_MAX/10000. +- Слишком длинные строки отклоняются; my_node_name явно усекается до 63 байтов с WARN. -## 3. API +## Секции -### Структуры +| Секция | Основные опции | +|---|---| +| global | ключи/node_id, client_type, TUN, MTU, keepalive, BBR, auto_sockets, log/debug, db_sync | +| server:name | addr, netif, so_mark, fib, type, transport, mtu, only_local, reality, socks | +| client:name | peer_public_key, link, keepalive и клиентские REALITY-параметры | +| network:name | id, pubkey, signing_key | +| routing | route_subnet, my_subnet | +| firewall | allow=all или IPv4[:port]; 0 означает весь IP, CIDR запрещён | +| control | ip/control_ip, port/control_port, allow/control_allow; порядок ip/port не влияет на итог | +| allowed_keys | allow_all, key (32-byte HEX) | +| nat | tun_ifname, tun_ip (IPv4), nat_via, port_start/end, forward=proto:node_id:IPv4:internal_port:external_port | +| tcp_proxy_client | enabled, tun_name/ip, mtu, via_node, socks/http_proxy enabled/addr, forward=port -> IPv4:port | +| tcp_proxy_server | tcp_recv_buf, fib, so_mark, netif, bind_ip, connect_timeout | +| chatserver / chat | db_path, join_password, allowed_groups, storage sizes, headless/audio binds и chat_setting API | +| ntp | enabled, server, interval | +| stun | enabled, server | +| reality | enabled, server_name, dest, short_id/short_ids, public/private_key, version, time_window, relay_idle_timeout, fingerprint | +| socks | enabled, addr, username, password | +| log_udp | ip, port | +| debug | category=level | +| gui | utun_enabled | -| Структура | Назначение | -|-----------|------------| -| `struct utun_config` | Корневой контейнер: `global` + связные списки `servers`, `clients`, `route_subnets`, `my_subnets`, `networks` | -| `struct global_config` | Глобальные настройки (ключи, TUN, таймауты, firewall, NAT, SOCKS/HTTP-прокси, NTP, debug, allowed_keys...) | -| `struct CFG_SERVER` | Локальный серверный линк: `addr` (ip:port), `netif_index`, `so_mark`, `fib`, `type`, `transport` (udp/tcp), `mtu`, `only_local` | -| `struct CFG_CLIENT` | Удалённый пир: `peer_public_key_hex`, `keepalive`, связный список `links` | -| `struct CFG_CLIENT_LINK` | Привязка клиента к локальному серверу: `local_srv` (указатель на `CFG_SERVER`), `remote_addr` | -| `struct CFG_ROUTE_ENTRY` | Маршрут: `IP` + `netmask` | -| `struct CFG_NETWORK` | Именованная сеть: `id` (56-bit), `pubkey_hex`, `signing_key_hex` | -| `struct CFG_FIREWALL_RULE` | Правило фаервола: `ip` (host order), `port`, `bypass` | -| `struct CFG_ALLOWED_KEY` | Разрешённый публичный ключ (32 байта binary) для контрольного сервера | -| `struct CFG_CONTROL_ALLOW` | Разрешённая подсеть для control-сервера | -| `struct CFG_NTP_SERVER` | NTP-сервер: имя хоста | +Наличие nat, tcp_proxy_client/server, chat/chatserver включает соответствующий режим даже при пустой секции; +chat также включает db_sync. Явный tcp_proxy_client enabled=no сохраняется при последующих опциях. +tun_enabled по умолчанию 0; при включении без имени используется tun0. +auto_sockets принимает no/yes/android, auto_socket_protocols — tcp/udp/tcp+udp (по умолчанию оба). +В Android frontend генерирует отдельные server записи. Addr temporary_ipv6:port/permanent_ipv6:port +задаёт выбор IPv6 интерфейса; обычный повторный addr сбрасывает этот режим. -### Секции конфига +## Диагностика и вспомогательные API -| Секция | Обработчик | Ключевые поля | -|--------|-----------|---------------| -| `[global]` | `parse_global()` | `my_node_name`, `my_private_key`, `my_public_key`, `my_node_id`, `tun_ifname`, `tun_ip`, `mtu`, `keepalive_*`, `bbr_max_cwnd`, `debug_level`, `log_file`, `db_path`, `db_sync_*`, `enable_timestamp`, `enable_colors`, `tun_test_mode` | -| `[server:name]` | `parse_server()` | `addr`, `so_mark`, `fib`, `netif`, `type` (public/nat/private/local), `transport` (udp/tcp), `mtu`, `only_local` | -| `[client:name]` | `parse_client()` | `link` (формат: `server_name:ip:port`), `peer_public_key`, `keepalive` | -| `[network:name]` | `parse_network()` | `id` (hex), `pubkey`, `signing_key` | -| `[routing]` | inline | `route_subnet`, `my_subnet` | -| `[firewall]` | `parse_firewall_rule()` | `allow=IPv4[:port]` или `allow=all`; порт 0–65535, 0 разрешает весь IP; CIDR запрещён | -| `[control]` | `parse_control()` | `ip`, `port`, `allow` (подсеть) | -| `[allowed_keys]` | inline | `allow_all`, `key` (64 hex) | -| `[nat]` | `parse_nat()` | `tun_ifname`, `tun_ip`, `nat_via`, `port_start`, `port_end`, `forward` (`proto:node_id_hex:IPv4:in_port:ext_port`) | -| `[tcp_proxy_client]` | `parse_tcp_proxy_client()` | `enabled`, `tun_name`, `tun_ip`, `mtu`, `via_node`, `socks_*`, `http_proxy_*`, `forward` (port -> ip:port) | -| `[tcp_proxy_server]` | inline | `tcp_recv_buf` | -| `[ntp]` | inline | `enabled`, `server`, `interval` | -| `[stun]` | `parse_stun()` | `enabled`, повторяющийся `server=host[:port]`; минимум два сервера при включении | -| `[gui]` | игнорируется | (зарезервировано) | +`print_config` выводит снимок в config/INFO. Private/signing keys заменены признаками наличия; +сети выводятся независимо от количества firewall rules. +`print_chatserver_invite_link` печатает bootstrap utun:// v3 в config/INFO и stderr при включённом чате: +channel_id=join_key=0, до 32 конфигурационных адресов, socketId=0, SNI до 63 байтов. +Это не invite конкретного канала и не снимок фактического bind/NAT. -### Функции - -| Функция | Назначение | -|---------|------------| -| `parse_config(filename)` | Читает и закрывает файл, затем вызывает `parse_config_from_buf`. Возвращает `utun_config*` или `NULL`. | -| `parse_config_from_buf(buf, len, filename)` | Парсит конфиг из буфера (in-memory). `filename` используется только для сообщений об ошибках. | -| `free_config(config)` | Рекурсивно освобождает все связные списки и сам `utun_config`. Безопасен для `NULL`. | -| `print_config(config)` | Выводит всё содержимое конфига через `DEBUG_INFO(DEBUG_CATEGORY_CONFIG, ...)`. Полезно для проверки после парсинга. | -| `update_config_keys(filename, priv_key, pub_key)` | **Устарела.** Дописывает ключи в конец файла. Используй `config_ensure_keys_and_node_id()` из `config_updater.h`. | - -### Внутренние хелперы (static) - -| Функция | Назначение | -|---------|------------| -| `config_ini_trim(str)` | Общий INI-код: удаляет пробелы слева и справа на месте, сохраняя адрес строки | -| `config_ini_key_value(line, ...)` | Общий INI-код: разбирает `key=value`, удаляет комментарий после `#` и пробелы | -| `parse_ip_with_netmask(str, ip, netmask)` | Парсит `1.2.3.4/24` или `::1/64` в `struct IP` | -| `parse_sockaddr(addr, port, sockaddr)` | Преобразует адрес+порт в `sockaddr_storage` через `getaddrinfo` | -| `parse_address_and_port(str, sockaddr)` | Разбирает строку `ip:port` → вызов `parse_sockaddr` | -| `get_netif_index(ifname)` | Получает индекс сетевого интерфейса через `if_nametoindex` | -| `parse_firewall_rule(rule_str, global)` | Строго разбирает `allow=IPv4[:port]` или `all`; ошибка/неизвестная опция firewall отменяет весь новый конфиг | -| `parse_control_allow(rule_str, global)` | Парсит `allow=IP[/CIDR]` | -| `config_ini_hex(hex_str, binary, len)` | Общий INI-код: проверяет весь HEX до декодирования | +`update_config_keys` — устаревшее добавление global-секции с двумя проверенными HEX-ключами в конец файла. +Возвращает 0/-1, проверяет запись/закрытие, но не атомарно и не согласует node_id; штатный путь — config_updater. +`config_peer_in_clients(config, pubkey[32])` возвращает 1 при бинарном совпадении ключа, 0 при отсутствии/NULL. From 7fe77a49c9a7e85e66a7c9d4c66b93d706998cb5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:30:18 +0100 Subject: [PATCH 14/45] Restore console diagnostics after parser dump assertions --- tests/test_config_parser.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config_parser.c b/tests/test_config_parser.c index b13dd7b4..931d407a 100644 --- a/tests/test_config_parser.c +++ b/tests/test_config_parser.c @@ -102,7 +102,7 @@ static void test_allocations(void) { struct utun_config* cfg = parse(text); assert(cfg); strcpy(cfg->global.my_private_key_hex, "0404040404040404040404040404040404040404040404040404040404040404"); char path[] = "/tmp/utun-parser-dump-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd); - debug_enable_file_output(path, 1); print_config(cfg); debug_disable_file_output(); + debug_enable_file_output(path, 1); print_config(cfg); debug_disable_file_output(); debug_enable_console(1); FILE* file = fopen(path, "rb"); assert(file); char output[8192]; size_t n = fread(output, 1, sizeof(output) - 1, file); output[n] = '\0'; fclose(file); unlink(path); assert(strstr(output, "[network:n]") && !strstr(output, cfg->global.my_private_key_hex) && !strstr(output, cfg->networks->signing_key_hex)); From e5e61723d8c84e36871c14891a783d8bf9cf3911 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:39:33 +0100 Subject: [PATCH 15/45] Inject config read faults through fortified fread as well --- tests/Makefile.am | 2 +- tests/test_config_updater.c | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Makefile.am b/tests/Makefile.am index 7753716f..60cca3e3 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -361,7 +361,7 @@ test_mem_LDFLAGS = -Wl,--wrap=malloc test_config_updater_SOURCES = test_config_updater.c test_config_updater_LDADD = $(LIBUTUN) $(COMMON_LIBS) -test_config_updater_LDFLAGS = -Wl,--wrap=fopen -Wl,--wrap=fdopen -Wl,--wrap=fread -Wl,--wrap=ferror \ +test_config_updater_LDFLAGS = -Wl,--wrap=fopen -Wl,--wrap=fdopen -Wl,--wrap=fread -Wl,--wrap=__fread_chk -Wl,--wrap=ferror \ -Wl,--wrap=fwrite -Wl,--wrap=fflush -Wl,--wrap=fclose -Wl,--wrap=fsync -Wl,--wrap=fchmod -Wl,--wrap=rename -Wl,--wrap=open \ -Wl,--wrap=u_malloc_impl -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl -Wl,--wrap=u_strdup_impl diff --git a/tests/test_config_updater.c b/tests/test_config_updater.c index 005711b8..f9638755 100644 --- a/tests/test_config_updater.c +++ b/tests/test_config_updater.c @@ -85,6 +85,10 @@ size_t __wrap_fread(void *buffer, size_t size, size_t count, FILE *file) { if (file == input_stream && inject(READ_DATA)) return 0; return __real_fread(buffer, size, count, file); } +size_t __wrap___fread_chk(void* buffer, size_t capacity, size_t size, size_t count, FILE* file) { + assert(!size || count <= capacity / size); + return __wrap_fread(buffer, size, count, file); +} int __wrap_ferror(FILE *file) { if (file == input_stream && fault == READ_DATA && injected) return 1; From 372b7d7f658c0cbc72687c9d97376693417e34bf Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:40:22 +0100 Subject: [PATCH 16/45] Retain removed instance callbacks until nested dispatch completes --- src/utun_instance.c | 51 ++++++++++++++++++++++++++++++++++----------- src/utun_instance.h | 4 ++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index 2cbfc170..c2c92d6e 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -1144,9 +1144,28 @@ struct UTUN_INSTANCE *utun_instance_reload(struct UTUN_INSTANCE *instance, struc return instance; } +/* Снятые записи нужны вложенным обходам до возврата из внешнего callback. */ +static void instance_collect_callbacks(struct UTUN_INSTANCE* instance) { + if (instance->lifecycle_callbacks_depth) return; + struct utun_activity_cbk_entry** activity = &instance->activity_cbks; + while (*activity) { + struct utun_activity_cbk_entry* entry = *activity; + if (entry->removed) { *activity = entry->next; u_free(entry); } + else activity = &entry->next; + } + struct peer_sleep_cbk_entry** sleep = &instance->peer_sleep_cbks; + while (*sleep) { + struct peer_sleep_cbk_entry* entry = *sleep; + if (entry->removed) { *sleep = entry->next; u_free(entry); } + else sleep = &entry->next; + } +} + void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg) { if (!instance || !fn) return; - struct utun_activity_cbk_entry* e = u_malloc(sizeof(struct utun_activity_cbk_entry)); + for (struct utun_activity_cbk_entry* e = instance->activity_cbks; e; e = e->next) + if (!e->removed && e->fn == fn && e->arg == arg) return; + struct utun_activity_cbk_entry* e = u_calloc(1, sizeof(*e)); if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "client activity subscription allocation failed"); return; } e->fn = fn; e->arg = arg; e->next = instance->activity_cbks; instance->activity_cbks = e; @@ -1156,27 +1175,32 @@ void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_ if (!instance || !fn) return; struct utun_activity_cbk_entry** p = &instance->activity_cbks; while (*p) { - if ((*p)->fn == fn && (*p)->arg == arg) { - struct utun_activity_cbk_entry* rm = *p; - *p = rm->next; u_free(rm); return; + if (!(*p)->removed && (*p)->fn == fn && (*p)->arg == arg) { + (*p)->removed = 1; instance_collect_callbacks(instance); return; } p = &(*p)->next; } } static void utun_fire_activity_cbk(struct UTUN_INSTANCE* instance, int active) { + instance->lifecycle_callbacks_depth++; + uint64_t version = instance->activity_version; struct utun_activity_cbk_entry* e = instance->activity_cbks; while (e) { struct utun_activity_cbk_entry* n = e->next; - e->fn(instance, active, e->arg); + if (!e->removed) e->fn(instance, active, e->arg); + if (instance->activity_version != version) break; e = n; } + instance->lifecycle_callbacks_depth--; instance_collect_callbacks(instance); } void utun_add_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_fn fn, void* arg) { if (!instance || !fn) return; - struct peer_sleep_cbk_entry* e = u_malloc(sizeof(*e)); - if (!e) return; + for (struct peer_sleep_cbk_entry* e = instance->peer_sleep_cbks; e; e = e->next) + if (!e->removed && e->fn == fn && e->arg == arg) return; + struct peer_sleep_cbk_entry* e = u_calloc(1, sizeof(*e)); + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "peer sleep subscription allocation failed"); return; } e->fn = fn; e->arg = arg; e->next = instance->peer_sleep_cbks; instance->peer_sleep_cbks = e; } @@ -1185,21 +1209,23 @@ void utun_remove_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_f if (!instance || !fn) return; struct peer_sleep_cbk_entry** p = &instance->peer_sleep_cbks; while (*p) { - if ((*p)->fn == fn && (*p)->arg == arg) { - struct peer_sleep_cbk_entry* rm = *p; - *p = rm->next; u_free(rm); return; + if (!(*p)->removed && (*p)->fn == fn && (*p)->arg == arg) { + (*p)->removed = 1; instance_collect_callbacks(instance); return; } p = &(*p)->next; } } void utun_fire_peer_sleep_cbk(struct UTUN_INSTANCE* instance, uint64_t peer_node_id, int sleeping) { - struct peer_sleep_cbk_entry* e = instance ? instance->peer_sleep_cbks : NULL; + if (!instance) return; + instance->lifecycle_callbacks_depth++; + struct peer_sleep_cbk_entry* e = instance->peer_sleep_cbks; while (e) { struct peer_sleep_cbk_entry* n = e->next; - e->fn(instance, peer_node_id, sleeping, e->arg); + if (!e->removed) e->fn(instance, peer_node_id, sleeping, e->arg); e = n; } + instance->lifecycle_callbacks_depth--; instance_collect_callbacks(instance); } /* Явная активность UI/звонка не истекает по таймеру: владелец сообщает завершение сам. */ @@ -1231,6 +1257,7 @@ int utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) { DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity effective=%s requested=%s stream_hold=%d node=%016llx type=%u", state ? "ACTIVE" : "STANDBY", requested ? "ACTIVE" : "STANDBY", held, (unsigned long long)instance->node_id, instance->client_type); + instance->activity_version++; utun_fire_activity_cbk(instance, state); return 0; } diff --git a/src/utun_instance.h b/src/utun_instance.h index 43963129..e42de737 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -115,6 +115,7 @@ struct utun_activity_cbk_entry { utun_activity_cbk_fn fn; void* arg; struct utun_activity_cbk_entry* next; + uint8_t removed; // tombstone до выхода из внешней рассылки }; // Подписка на смену спячки пира (keepalive-протокол). Сервер (или любой узел) @@ -125,6 +126,7 @@ struct peer_sleep_cbk_entry { peer_sleep_cbk_fn fn; void* arg; struct peer_sleep_cbk_entry* next; + uint8_t removed; // снятая подписка не вызывается, память удерживает текущая рассылка }; // Состояние одного узла. Вложенные модули освобождаются через их lifecycle API. @@ -273,6 +275,8 @@ struct UTUN_INSTANCE { uint8_t incoming_call_policy; // CALL_POLICY_*: независимые настройки публикации и допуска входящих struct utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive) + unsigned lifecycle_callbacks_depth; // общая вложенность рассылок activity/peer_sleep + uint64_t activity_version; // смена эффективной активности отменяет остаток более старой рассылки // TCP proxy server (exit node) struct tcp_proxy_server tcp_proxy_server; From 7ea1fa99541b1c410bab39bdbc5ce555eef25ece Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:42:18 +0100 Subject: [PATCH 17/45] Validate instance construction and retain the global log path safely --- src/utun_instance.c | 119 ++++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 65 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index c2c92d6e..f1dfba7d 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -51,10 +51,6 @@ #include #include #include -#ifndef _WIN32 -#include -#endif -#include #include #include "../lib/platform_compat.h" #include "../lib/mem.h" @@ -64,12 +60,11 @@ // Forward declarations -static uint32_t get_dest_ip(const uint8_t *packet, size_t len); static int mkdir_recursive(const char* path); static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config); -// Global instance for signal handlers -static struct UTUN_INSTANCE *g_instance = NULL; +// Путь глобального logger должен переживать config/instance, поскольку debug API заимствует строку. +static char g_config_log_path[256]; // Global flag to control TUN initialization (disabled by default) static int g_tun_init_enabled = 0; @@ -138,8 +133,11 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u /* Always verify node_id matches pubkey, fix if mismatched */ { uint8_t pub_bin[SC_PUBKEY_SIZE]; - sc_compute_public_key_from_private(instance->my_keys.private_key, pub_bin); + if (sc_compute_public_key_from_private(instance->my_keys.private_key, pub_bin) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, "cannot compute instance X25519 public key"); return -1; + } uint64_t derived = sc_derive_node_id_from_pubkey(pub_bin); + if (!derived) { DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, "instance identity derived zero node_id"); return -1; } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "node_id check: config=%016llx derived=%016llx match=%d", (unsigned long long)instance->node_id, (unsigned long long)derived, (instance->node_id == derived)); @@ -158,7 +156,9 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u } { uint8_t ed_priv_hash[SHA512_DIGEST_LENGTH]; - SHA512(instance->my_keys.private_key, SC_PRIVKEY_SIZE, ed_priv_hash); + if (!SHA512(instance->my_keys.private_key, SC_PRIVKEY_SIZE, ed_priv_hash)) { + DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, "cannot derive instance Ed25519 seed"); return -1; + } memcpy(instance->my_ed25519_privkey, ed_priv_hash, SC_PRIVKEY_SIZE); } DEBUG_INFO(DEBUG_CATEGORY_CRYPTO, "Ed25519 pubkey derived successfully"); @@ -173,7 +173,7 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u // Initialize connection queue instance->connections = queue_new(ua, 256, 0, 8, "connections"); if (!instance->connections) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "Failed to create connections queue"); return -1; } - instance->tcp_connections = queue_new(ua, sizeof(struct tcp_conn_entry), sizeof(uint64_t), 8, "tcpconn"); + instance->tcp_connections = queue_new(ua, 256, offsetof(struct tcp_conn_entry, node_id), sizeof(uint64_t), "tcpconn"); if (!instance->tcp_connections) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "Failed to create tcp_connections queue"); return -1; } struct CFG_NETWORK *net = config->networks; while (net) { @@ -184,15 +184,15 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u } struct NETWORK_ENTRY *ne = (struct NETWORK_ENTRY*)entry->data; ne->id = net->id & 0x00FFFFFFFFFFFFFFULL; - for (int i = 0; i < SC_PUBKEY_SIZE; i++) { - unsigned int b; sscanf(net->pubkey_hex + i * 2, "%2x", &b); ne->pubkey[i] = (uint8_t)b; - } - for (int i = 0; i < SC_PRIVKEY_SIZE; i++) { - unsigned int b; sscanf(net->signing_key_hex + i * 2, "%2x", &b); ne->signing_key[i] = (uint8_t)b; + if (sc_hex_to_binary(net->pubkey_hex, ne->pubkey, sizeof(ne->pubkey)) != SC_OK || + sc_hex_to_binary(net->signing_key_hex, ne->signing_key, sizeof(ne->signing_key)) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid keys for network %s", net->name); queue_entry_free(entry); return -1; } strncpy(ne->name, net->name, sizeof(ne->name) - 1); ne->name[sizeof(ne->name) - 1] = '\0'; - queue_data_put_with_index(instance->networks, entry); + if (queue_data_put_with_index(instance->networks, entry) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot index configured network %s", net->name); return -1; + } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Network added: [%s] id=%012llx", ne->name, (unsigned long long)ne->id); net = net->next; } @@ -217,7 +217,7 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "Socket initialization failed: result=%d", socket_result); return -1; } - if (config->global.db_path[0]) mkdir_recursive(config->global.db_path); + if (config->global.db_path[0] && mkdir_recursive(config->global.db_path) < 0) return -1; if (g_topo_group_enabled) { instance->topo_groups = topo_groups_init(instance); if (!instance->topo_groups) { @@ -322,6 +322,9 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u // Create and initialize root instance from config file struct UTUN_INSTANCE* utun_instance_create(struct UASYNC* ua, const char *config_file) { + if (!ua || !config_file || !*config_file) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "instance create requires uasync and a config path"); return NULL; + } // Ensure keys and node_id exist in config (generates them if missing) if (config_ensure_keys_and_node_id(config_file) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Failed to ensure keys and node_id in config: %s", config_file); @@ -335,38 +338,8 @@ struct UTUN_INSTANCE* utun_instance_create(struct UASYNC* ua, const char *config return NULL; } - // Open log file only if not using global debug system output - if (config->global.log_file[0]) { - debug_set_output_file(config->global.log_file); - } - - // Apply debug level from config - if (config->global.debug_level[0]) { - debug_apply_global_level(config->global.debug_level); - } - - // Apply per-category debug levels from [debug] section - for (int i = 0; i < config->global.debug_levels.count; i++) { - debug_apply_category_config( - config->global.debug_levels.category[i], - config->global.debug_levels.level[i] - ); - } - - // Allocate instance - struct UTUN_INSTANCE *instance = u_calloc(1, sizeof(struct UTUN_INSTANCE)); - if (!instance) { - free_config(config); - return NULL; - } - instance->etcp_connect_timeout_tb = 20000; - - // Initialize using common function - if (instance_init_common(instance, ua, config) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Core creation failed; releasing all initialized resources"); - instance_destroy(instance, 1); - return NULL; - } + struct UTUN_INSTANCE* instance = utun_instance_create_from_config(ua, config); + if (!instance) { free_config(config); return NULL; } // Log instance info if (instance->name[0] != '\0') { @@ -382,10 +355,14 @@ struct UTUN_INSTANCE* utun_instance_create(struct UASYNC* ua, const char *config // Create instance from existing config structure (config ownership transfers to instance) struct UTUN_INSTANCE* utun_instance_create_from_config(struct UASYNC* ua, struct utun_config* config) { - if (!config) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "NULL config"); + if (!ua || !config) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "instance create requires uasync and config"); return NULL; } + if (config->global.log_file[0]) { + snprintf(g_config_log_path, sizeof(g_config_log_path), "%s", config->global.log_file); + debug_set_output_file(g_config_log_path); + } if (config->global.debug_level[0]) debug_apply_global_level(config->global.debug_level); @@ -412,15 +389,23 @@ struct UTUN_INSTANCE* utun_instance_create_from_config(struct UASYNC* ua, struct // Create instance from config text string (writes to temp file, parses, cleans up) struct UTUN_INSTANCE* utun_instance_create_from_str(struct UASYNC* ua, const char* config_text) { - if (!config_text) return NULL; + if (!ua || !config_text) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "instance create requires uasync and config text"); return NULL; } char tmp_path[] = "/tmp/utun_cfg_XXXXXX"; int fd = mkstemp(tmp_path); - if (fd < 0) return NULL; + if (fd < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot create temporary config: %s", strerror(errno)); return NULL; } size_t len = strlen(config_text); - if (write(fd, config_text, len) != (ssize_t)len) { close(fd); unlink(tmp_path); return NULL; } - close(fd); + size_t written = 0; + int failed = 0; + while (written < len) { + ssize_t n = write(fd, config_text + written, len - written); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "temporary config write failed at %zu: %s", written, strerror(errno)); failed = 1; break; } + written += (size_t)n; + } + if (close(fd)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "temporary config close failed: %s", strerror(errno)); failed = 1; } + if (failed) { if (unlink(tmp_path)) DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "temporary config cleanup failed: %s", strerror(errno)); return NULL; } struct UTUN_INSTANCE* inst = utun_instance_create(ua, tmp_path); - unlink(tmp_path); + if (unlink(tmp_path)) DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "temporary config cleanup failed: %s", strerror(errno)); return inst; } @@ -623,11 +608,6 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { // This allows sharing uasync between multiple instances instance->ua = NULL; - // Clear global instance - if (g_instance == instance) { - g_instance = NULL; - } - // Free the instance memory DEBUG_INFO(DEBUG_CATEGORY_SYS, "[INSTANCE_DESTROY] Freeing instance memory"); @@ -679,11 +659,20 @@ void utun_instance_stop(struct UTUN_INSTANCE *instance) { static int mkdir_recursive(const char *path) { char tmp[512]; int len = snprintf(tmp, sizeof(tmp), "%s", path); - if (len <= 0 || (size_t)len >= sizeof(tmp)) return -1; - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { *p = '\0'; utun_mkdir(tmp, 0755); *p = '/'; } + if (len <= 0 || (size_t)len >= sizeof(tmp)) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid database directory length=%d", len); return -1; } + for (char *p = tmp + 1;; p++) { + if (*p && *p != '/') continue; + if (*p == '/' && p == tmp + 2 && tmp[1] == ':') continue; // Windows drive root + char saved = *p; *p = '\0'; + if (utun_mkdir(tmp, 0755) < 0) { + int err = errno; struct stat st; + if (err != EEXIST || stat(tmp, &st) < 0 || !S_ISDIR(st.st_mode)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot create database directory %s: %s", tmp, strerror(err)); return -1; + } + } + *p = saved; if (!saved) break; } - return utun_mkdir(tmp, 0755); + return 0; } int utun_core_start(struct UTUN_INSTANCE *instance) { From d681d5abf05e1b78ef81b78791967ca2fcd4c04d Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:56:07 +0100 Subject: [PATCH 18/45] Record parser boundary, rollback and sanitizer audit evidence --- doc/config_parser_audit_findings.txt | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 doc/config_parser_audit_findings.txt diff --git a/doc/config_parser_audit_findings.txt b/doc/config_parser_audit_findings.txt new file mode 100644 index 00000000..d46069f1 --- /dev/null +++ b/doc/config_parser_audit_findings.txt @@ -0,0 +1,53 @@ +Аудит config_parser — 26.10.2026 + +Полностью прочитаны config_parser.c/.h, config_parser_doc.md и общий config_ini.h. +Для согласования строгого разбора также повторно сверены config_updater.c/.h/_doc.md. +Разбор сохраняет модель и владение free_config; запуск ресурсов остаётся у instance. + +Исправления +- IPv6 /32 больше не подменяется /128; префиксы проверяются до сужения к uint8_t. + Control allow=/0 задаёт нулевую маску без сдвига на 32; IPv6 control allow отклоняется. +- Ошибки известных обработчиков и выделения памяти отклоняют всю модель. + Все частичные списки освобождаются, включая client links и не завершённую секцию. +- Числа проверяются целиком до преобразования: знаки, хвост, диапазон и overflow. + Keepalive interval помещается в uint16_t; таймеры ограничены до умножения. + Bool унифицирован: 0/1, no/yes, false/true. Версия — ровно три десятичных октета. +- Адресные порты проверяются как decimal 0..65535, SOCKS — 1..65535. + Control ip/port даёт одинаковый результат в обоих порядках; ошибки getaddrinfo не теряются. +- Storage sizes проверяют UINT64_MAX, множитель M/G и полный хвост; -1 не становится безлимитом, + переполнение не становится нулевым лимитом. Слишком длинные строковые настройки отклоняются. +- Network ID ограничен 56 битами; требуются оба 32-byte HEX ключа. + Предоставленные ключи идентичности/пиров проверяются целиком; пустые необязательные остаются незаданными. +- CFG_CLIENT_LINK сохраняет имя local_srv. Повторный обычный addr сбрасывает режим dynamic IPv6. +- Флаг tcp_proxy_client enabled=no не включается снова следующей опцией той же секции. + Наличие service-секции задаёт её default enable и при отсутствии опций. +- Print_config не печатает private/signing keys; Networks выведены за пределы firewall-цикла. + Устаревший append ключей проверяет вход, запись и fclose; не объявляется атомарным updater. +- Updater выбирает итоговую идентичность/REALITY по последнему вхождению, но исправляет также + синтаксически плохие ранние дубли выбранными значениями. Из-за такого дубля ключи не генерируются заново. + Это нужно, поскольку строгий парсер проверяет каждое вхождение. Булевы REALITY enabled согласованы. + +Контракты и ограничения +Обновлены заголовок и документ: порядок списков, необъединяемые named-секции, remote-only links, +синхронный DNS и первый getaddrinfo-адрес, Windows socket init, default-значения и границы, +повторные опции, поведение неизвестных секций/debug/gui и независимость parser/updater/start. +Bootstrap invite printer использует конфигурационные адреса и нулевые channel_id/join_key; +это не снимок фактических bind/NAT-адресов и не invite конкретного канала. +Нативные сборки/runtime Windows и FreeBSD в этом этапе не выполнялись. + +Проверки +- Профиль 6/6 PASS: config_parser, config_updater, config_debug, reality_config, ncd_config, services. +- Test_config_parser: 53 ошибочных конфигурации отвергнуты одинаково из файла/буфера; + 10 отказов аллокаций с полным rollback, IPv4/IPv6 CIDR и control order, предельные размеры/порты, + скрытие секретов и /dev/full при append; баланс памяти сохранён. +- ASAN/UBSan/LSan: те же 6/6 PASS с detect_leaks=1/ halt_on_error=1. + Все встроенные lib/src/Opus инструментированы согласованно в отдельной копии + /tmp/utun-config-instance-asan-20261026; рабочие объекты не смешивались с sanitizer-объектами. + Первый updater sanitizer-прогон обнаружил обход test mock через glibc __fread_chk; + checked wrapper добавлен, повторный прогон всех файловых и allocation faults PASS. +- Логи: /tmp/config-parser-audit-contract.log, /tmp/config-parser-audit-updater.log, + /tmp/config-parser-audit-debug.log, /tmp/config-parser-audit-reality.log, + /tmp/config-parser-audit-ncd.log, /tmp/config-parser-audit-services.log. + Санитайзеры: соответствующие /tmp/config-parser-audit-asan-*.log, + сборка /tmp/config-parser-audit-asan-build.log и /tmp/config-parser-audit-asan-tests-build.log. + Общая сборка: /tmp/config-instance-audit-full-build.log. From b44ea19b4b024cb5b17fe5a7ed17bfe99cf89419 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 00:56:36 +0100 Subject: [PATCH 19/45] Close config parser audit after the full integration suite --- doc/config_parser_audit_findings.txt | 2 ++ doc/tasks.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/doc/config_parser_audit_findings.txt b/doc/config_parser_audit_findings.txt index d46069f1..d94f7aa0 100644 --- a/doc/config_parser_audit_findings.txt +++ b/doc/config_parser_audit_findings.txt @@ -36,6 +36,8 @@ Bootstrap invite printer использует конфигурационные Нативные сборки/runtime Windows и FreeBSD в этом этапе не выполнялись. Проверки +- Полный check.sh: 195/195 PASS, 0 failed/0 skipped, proxy9/9, burst1/1, load8/8, + 134217728 байт, 169 секунд; /tmp/config-instance-audit-first-check.log. - Профиль 6/6 PASS: config_parser, config_updater, config_debug, reality_config, ncd_config, services. - Test_config_parser: 53 ошибочных конфигурации отвергнуты одинаково из файла/буфера; 10 отказов аллокаций с полным rollback, IPv4/IPv6 CIDR и control order, предельные размеры/порты, diff --git a/doc/tasks.md b/doc/tasks.md index 96b3f8c9..136864e4 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -19,6 +19,14 @@ /tmp/stream-member-fix-full-check.log. Полный аудит остальных контрактов stream этим не закрывается. Подробности: [stream_audit_findings.txt](stream_audit_findings.txt). +[+] **config_parser: завершить сверку и строгий разбор значений** — завершено 26.10.2026. + Исправлены IPv6 /32, control /0 и порядок ip/port, числовые границы/таймеры, storage overflow, + потерянные ошибки обработчиков и памяти, повторное включение proxy, HEX и диагностический dump секретов. + Config updater нормализует ошибочные ранние дубли выбранными последними значениями без смены идентичности. + Заголовок и документы согласованы; профиль6/6, ASAN/UBSan/LSan6/6, detect_leaks=1. + Полный check.sh195/195 PASS,0 skipped,proxy9/9,burst1/1,load8/8,134217728 байт,169с. + Подробности, платформенные ограничения и логи: [config_parser_audit_findings.txt](config_parser_audit_findings.txt). + [+] **transport_layer: завершить аудит заголовков и документов** — завершено 26.10.2026. С учётом прежних этапов сверены все32 собственных заголовка и17 документов каталога плюс doc/stun_nat.md. Закрыты auto_socket/socket_monitor/SOCKS/call_ring/REALITY/STUN; исправлены владение endpoints/posts, From 5e63bc2a4297d5ec4cdd501f0779b6639d79ffe7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:07:07 +0100 Subject: [PATCH 20/45] Share atomic network index construction and typed socket teardown --- src/utun_instance.c | 70 ++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index f1dfba7d..b382a117 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -93,11 +93,49 @@ static int local_sockaddr_equal(const struct sockaddr_storage *a, const struct s if (a->ss_family == AF_INET6) { const struct sockaddr_in6 *ia = (const struct sockaddr_in6 *)a; const struct sockaddr_in6 *ib = (const struct sockaddr_in6 *)b; - return memcmp(&ia->sin6_addr, &ib->sin6_addr, 16) == 0 && ia->sin6_port == ib->sin6_port; + return memcmp(&ia->sin6_addr, &ib->sin6_addr, 16) == 0 && ia->sin6_port == ib->sin6_port && ia->sin6_scope_id == ib->sin6_scope_id; } return 0; } +/* Собирает самостоятельный индекс сетей; любой отказ освобождает только новую очередь. */ +static struct ll_queue* instance_build_networks(struct UASYNC* ua, const struct CFG_NETWORK* networks) { + struct ll_queue* queue = queue_new(ua, 16, offsetof(struct NETWORK_ENTRY, id), 7, "networks"); + if (!queue) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot create network index"); return NULL; } + for (const struct CFG_NETWORK* net = networks; net; net = net->next) { + struct ll_entry* entry = queue_entry_new(sizeof(struct NETWORK_ENTRY)); + if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot allocate network %s", net->name); goto fail; } + struct NETWORK_ENTRY* item = (struct NETWORK_ENTRY*)entry->data; + if (net->id > UINT64_C(0x00ffffffffffffff) || + sc_hex_to_binary(net->pubkey_hex, item->pubkey, sizeof(item->pubkey)) != SC_OK || + sc_hex_to_binary(net->signing_key_hex, item->signing_key, sizeof(item->signing_key)) != SC_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid id/keys for network %s", net->name); queue_entry_free(entry); goto fail; + } + item->id = net->id; snprintf(item->name, sizeof(item->name), "%s", net->name); + if (queue_data_put_with_index(queue, entry) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot index network %s", net->name); goto fail; + } + DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "network indexed name=%s id=%014llx", net->name, (unsigned long long)net->id); + } + return queue; +fail: + queue_clear(queue); queue_free(queue); return NULL; +} + +/* Удаляет listener и правильную модель UDP/TCP, не оставляя сервер с заимствованным socket. */ +static void instance_remove_socket(struct UTUN_INSTANCE* instance, struct ETCP_SOCKET* socket) { + uint8_t id = socket->sock_id; uint64_t generation = socket->generation; + ncd_remove_socket_links(instance, socket); + socket = instance->etcp_sockets; + while (socket && (socket->sock_id != id || socket->generation != generation)) socket = socket->next; + if (!socket) return; // callback уже удалил endpoint + if (socket->is_tcp) { + struct stcp_server* listener = stcp_server_list_find_by_sock(instance, socket); + if (listener) { stcp_server_list_remove(instance, listener); stcp_link_server_destroy(listener); } + tcp_socket_remove(socket); + } else etcp_socket_remove(socket); +} + // Common initialization function (called by both create functions) // Returns 0 on success, -1 on error (instance is NOT freed on error - caller must handle) static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* ua, struct utun_config* config) { @@ -164,38 +202,12 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u DEBUG_INFO(DEBUG_CATEGORY_CRYPTO, "Ed25519 pubkey derived successfully"); if (etcp_padding_start(&instance->padding, ua, instance->name) < 0) return -1; - // Initialize networks queue (indexed by 56-bit network id) - instance->networks = queue_new(ua, 16, offsetof(struct NETWORK_ENTRY, id), 7, "networks"); - if (!instance->networks) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Failed to create networks queue"); - return -1; - } - // Initialize connection queue + instance->networks = instance_build_networks(ua, config->networks); + if (!instance->networks) return -1; instance->connections = queue_new(ua, 256, 0, 8, "connections"); if (!instance->connections) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "Failed to create connections queue"); return -1; } instance->tcp_connections = queue_new(ua, 256, offsetof(struct tcp_conn_entry, node_id), sizeof(uint64_t), "tcpconn"); if (!instance->tcp_connections) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "Failed to create tcp_connections queue"); return -1; } - struct CFG_NETWORK *net = config->networks; - while (net) { - struct ll_entry *entry = queue_entry_new(sizeof(struct NETWORK_ENTRY)); - if (!entry) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Failed to allocate network entry for %s", net->name); - return -1; - } - struct NETWORK_ENTRY *ne = (struct NETWORK_ENTRY*)entry->data; - ne->id = net->id & 0x00FFFFFFFFFFFFFFULL; - if (sc_hex_to_binary(net->pubkey_hex, ne->pubkey, sizeof(ne->pubkey)) != SC_OK || - sc_hex_to_binary(net->signing_key_hex, ne->signing_key, sizeof(ne->signing_key)) != SC_OK) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid keys for network %s", net->name); queue_entry_free(entry); return -1; - } - strncpy(ne->name, net->name, sizeof(ne->name) - 1); - ne->name[sizeof(ne->name) - 1] = '\0'; - if (queue_data_put_with_index(instance->networks, entry) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "cannot index configured network %s", net->name); return -1; - } - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Network added: [%s] id=%012llx", ne->name, (unsigned long long)ne->id); - net = net->next; - } // Create memory pools instance->ack_pool = memory_pool_init(sizeof(struct ACK_PACKET), "ack_pool"); From a7d2333deb794b954cf7cf9e48406805cd746687 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:08:54 +0100 Subject: [PATCH 21/45] Reload complete endpoint settings and restart changed resource owners --- src/utun_instance.c | 269 +++++++++++++++++++++++--------------------- 1 file changed, 143 insertions(+), 126 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index b382a117..cc439427 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -1004,144 +1004,161 @@ void utun_instance_diagnose_leaks(struct UTUN_INSTANCE *instance, const char *ph report.etcp_connections_count); } -/* Variant B: selective reload. Compares settings for [server:], [client:], link=. Unchanged untouched (no close, no timers, no queues). Changed/deleted: target only that object. New: add. Routing/fw always. BGP self-restarts. */ -struct UTUN_INSTANCE *utun_instance_reload(struct UTUN_INSTANCE *instance, struct UASYNC *ua, const char *config_file) { - if (!instance || !ua || !config_file) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "invalid arguments"); - return NULL; +/* Сравнивает фактические настройки endpoint, независимо от выбранного ОС ephemeral порта. */ +static int instance_server_equal(const struct CFG_SERVER* a, const struct CFG_SERVER* b) { + return a && b && !strcmp(a->name, b->name) && local_sockaddr_equal(&a->ip, &b->ip) && + a->netif_index == b->netif_index && a->so_mark == b->so_mark && a->fib == b->fib && a->type == b->type && + a->transport == b->transport && a->reality_enabled == b->reality_enabled && a->socks_enabled == b->socks_enabled && + a->ipv6_mode == b->ipv6_mode && a->mtu == b->mtu && a->only_local == b->only_local; +} + +static int instance_servers_equal(const struct CFG_SERVER* a, const struct CFG_SERVER* b) { + while (a && b) { if (!instance_server_equal(a, b)) return 0; a = a->next; b = b->next; } + return !a && !b; +} + +static int instance_routes_equal(const struct CFG_ROUTE_ENTRY* a, const struct CFG_ROUTE_ENTRY* b) { + while (a && b) { + if (a->netmask != b->netmask || a->ip.family != b->ip.family || + memcmp(&a->ip.addr, &b->ip.addr, a->ip.family == AF_INET ? 4 : 16)) return 0; + a = a->next; b = b->next; } - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "SIGHUP selective reload from %s", config_file); + return !a && !b; +} + +/* Горячие global-поля: firewall, уровни debug и STUN. Остальным владельцам нужен restart. + * Указатели сравниваются по содержимому; padding модели может лишь консервативно выбрать restart. */ +static int instance_globals_equal(const struct global_config* a, const struct global_config* b) { + if (a->control_allow_count != b->control_allow_count || + (a->control_allow_count && memcmp(a->control_allows, b->control_allows, a->control_allow_count * sizeof(*a->control_allows)))) return 0; + const struct CFG_NTP_SERVER* na = a->ntp_servers; const struct CFG_NTP_SERVER* nb = b->ntp_servers; + while (na && nb) { if (strcmp(na->name, nb->name)) return 0; na = na->next; nb = nb->next; } + if (na || nb) return 0; + const struct CFG_ALLOWED_KEY* ka = a->allowed_keys; const struct CFG_ALLOWED_KEY* kb = b->allowed_keys; + while (ka && kb) { if (memcmp(ka->key_bin, kb->key_bin, sizeof(ka->key_bin))) return 0; ka = ka->next; kb = kb->next; } + if (ka || kb) return 0; + struct global_config x = *a, y = *b; + x.control_allows = y.control_allows = NULL; x.ntp_servers = y.ntp_servers = NULL; x.allowed_keys = y.allowed_keys = NULL; + x.firewall_rules = y.firewall_rules = NULL; x.firewall_rule_count = y.firewall_rule_count = 0; + x.firewall_bypass_all = y.firewall_bypass_all = 0; + x.stun_servers = y.stun_servers = NULL; x.stun_server_count = y.stun_server_count = 0; x.stun_enabled = y.stun_enabled = 0; + memset(x.debug_level, 0, sizeof(x.debug_level)); memset(y.debug_level, 0, sizeof(y.debug_level)); + memset(&x.debug_levels, 0, sizeof(x.debug_levels)); memset(&y.debug_levels, 0, sizeof(y.debug_levels)); + return !memcmp(&x, &y, sizeof(x)); +} + +static int instance_clients_equal(const struct CFG_CLIENT* a, const struct CFG_CLIENT* b) { + while (a && b) { + if (strcmp(a->name, b->name) || strcmp(a->peer_public_key_hex, b->peer_public_key_hex) || + a->keepalive != b->keepalive || a->reality_enabled != b->reality_enabled || + memcmp(&a->reality, &b->reality, sizeof(a->reality))) return 0; + const struct CFG_CLIENT_LINK* la = a->links; const struct CFG_CLIENT_LINK* lb = b->links; + while (la && lb) { + const char* sa = la->local_srv ? la->local_srv->name : la->server_name; + const char* sb = lb->local_srv ? lb->local_srv->name : lb->server_name; + if (strcmp(sa, sb) || !local_sockaddr_equal(&la->remote_addr, &lb->remote_addr)) return 0; + la = la->next; lb = lb->next; + } + if (la || lb) return 0; + a = a->next; b = b->next; + } + return !a && !b; +} + +/* Готовит конфиг/firewall/индекс до мутаций. Изменения владельцев требуют полного restart; + * ручные endpoint обновляются поимённо, без сравнения port=0 с фактическим портом ОС. */ +struct UTUN_INSTANCE* utun_instance_reload(struct UTUN_INSTANCE* instance, struct UASYNC* ua, const char* config_file) { + if (!instance || !ua || ua != instance->ua || !config_file || !*config_file) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload requires instance, its owning uasync and a config path"); return instance; + } + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "reload preparing %s", config_file); debug_reopen_log(); - struct utun_config *new_config = parse_config(config_file); - if (!new_config) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Failed to parse new config"); - return instance; + if (config_ensure_keys_and_node_id(config_file) < 0) return instance; + struct utun_config* next = parse_config(config_file); + if (!next) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload parse failed; previous instance retained"); return instance; } + struct firewall_ctx firewall = {0}; + if (fw_load_rules(&firewall, &next->global) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload firewall preparation failed; previous instance retained"); + fw_free(&firewall); free_config(next); return instance; } - struct firewall_ctx next_fw = {0}; - if (fw_load_rules(&next_fw, &new_config->global) < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Reload rejected: cannot prepare firewall; previous configuration retained"); - free_config(new_config); - return instance; - } - - // Reset + apply debug categories from [debug] section (always, for partial and full paths). - // Mirrors utun_instance_create exactly. Reset ensures removed categories fall back to global. - for (int i = 0; i < DEBUG_CATEGORY_COUNT; i++) { - g_debug_config.category_levels[i] = DEBUG_LEVEL_NONE; - } - if (new_config->global.debug_level[0]) { - debug_apply_global_level(new_config->global.debug_level); - } - for (int i = 0; i < new_config->global.debug_levels.count; i++) { - debug_apply_category_config( - new_config->global.debug_levels.category[i], - new_config->global.debug_levels.level[i] - ); - } - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Applied [debug] section (%d categories)", new_config->global.debug_levels.count); - - int needs_full = 0; - if (strcmp(instance->config->global.my_public_key_hex, new_config->global.my_public_key_hex) != 0 || - strcmp(instance->config->global.my_private_key_hex, new_config->global.my_private_key_hex) != 0 || - instance->config->global.my_node_id != new_config->global.my_node_id || - strcmp(instance->config->global.tun_ifname, new_config->global.tun_ifname) != 0) { - needs_full = 1; - } - if (needs_full) { - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Incompatible global - full reload"); - int restart_core = instance->core_started, restart_utun = instance->utun_started, restart_chat = instance->chat_started; - fw_free(&next_fw); + struct utun_config* previous = instance->config; + int servers_changed = !instance_servers_equal(previous->servers, next->servers); + int full = !instance_globals_equal(&previous->global, &next->global) || + !instance_routes_equal(previous->route_subnets, next->route_subnets) || + !instance_routes_equal(previous->my_subnets, next->my_subnets) || + (next->global.auto_sockets != CFG_AUTO_SOCKETS_OFF && servers_changed); + for (unsigned i = 0; i < DEBUG_CATEGORY_COUNT; i++) g_debug_config.category_levels[i] = DEBUG_LEVEL_NONE; + if (next->global.debug_level[0]) debug_apply_global_level(next->global.debug_level); + for (int i = 0; i < next->global.debug_levels.count; i++) + debug_apply_category_config(next->global.debug_levels.category[i], next->global.debug_levels.level[i]); + if (full) { + int core = instance->core_started, utun = instance->utun_started, chat = instance->chat_started, running = instance->running; + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "full reload: core=%d utun=%d chat=%d running=%d", core, utun, chat, running); + fw_free(&firewall); utun_instance_destroy(instance); - struct UTUN_INSTANCE *new_instance = utun_instance_create(ua, config_file); - if (!new_instance || (restart_core && utun_core_start(new_instance) < 0) || - (restart_utun && utun_service_start(new_instance) < 0) || (restart_chat && chat_service_start(new_instance) < 0)) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Full reload failed"); - if (new_instance) utun_instance_destroy(new_instance); - free_config(new_config); - return NULL; + instance = utun_instance_create_from_config(ua, next); + if (!instance) { free_config(next); DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "full reload create failed; previous instance destroyed"); return NULL; } + if ((core && utun_core_start(instance) < 0) || (utun && utun_service_start(instance) < 0) || + (chat && chat_service_start(instance) < 0)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "full reload service restart failed"); utun_instance_destroy(instance); return NULL; } - new_instance->running = 1; - free_config(new_config); - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Full reload completed"); - return new_instance; + instance->running = running; + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "full reload completed"); return instance; } - // Selective Variant B (settings compare, only affected updated) - // Servers [server:] - for (struct CFG_SERVER *ns = new_config->servers; ns; ns = ns->next) { - int matched = 0; - for (struct ETCP_SOCKET *os = instance->etcp_sockets; os; os = os->next) { - if (strcmp(ns->name, os->name) == 0 && local_sockaddr_equal(&ns->ip, &os->local_addr)) { - matched = 1; - if (ns->mtu != os->mtu) { - os->mtu = ns->mtu; - DEBUG_INFO(DEBUG_CATEGORY_ETCP, "Updated socket settings for %s", ns->name); - } - break; - } - } - if (!matched) { - etcp_socket_add(instance, ns); - DEBUG_INFO(DEBUG_CATEGORY_ETCP, "Added new socket %s", ns->name); - } + struct ll_queue* networks = instance_build_networks(ua, next->networks); + if (!networks) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload network preparation failed; previous instance retained"); + fw_free(&firewall); free_config(next); return instance; } - struct ETCP_SOCKET *os = instance->etcp_sockets; - while (os) { - struct ETCP_SOCKET *next = os->next; - int found = 0; - for (struct CFG_SERVER *ns = new_config->servers; ns; ns = ns->next) { - if (strcmp(ns->name, os->name) == 0) { - found = 1; break; + int socket_result = 0; + if (next->global.auto_sockets == CFG_AUTO_SOCKETS_OFF) { + for (unsigned id = 0; id < 256; id++) { + struct ETCP_SOCKET* socket = instance->etcp_sockets; + while (socket && socket->sock_id != id) socket = socket->next; + if (!socket || socket->probe_only) continue; + const struct CFG_SERVER* old = previous->servers; const struct CFG_SERVER* updated = next->servers; + while (old && strcmp(old->name, socket->name)) old = old->next; + if (!old) continue; // endpoint другого владельца не относится к server-конфигу + while (updated && strcmp(updated->name, socket->name)) updated = updated->next; + if (!instance_server_equal(old, updated)) { + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "reload removing changed/deleted endpoint %s id=%u", socket->name, id); + instance_remove_socket(instance, socket); servers_changed = 1; } } - if (!found) { - etcp_socket_remove(os); - DEBUG_INFO(DEBUG_CATEGORY_ETCP, "Removed deleted socket %s", os->name); + for (struct CFG_SERVER* server = next->servers; server; server = server->next) { + struct ETCP_SOCKET* socket = instance->etcp_sockets; + while (socket && strcmp(socket->name, server->name)) socket = socket->next; + if (socket) continue; + servers_changed = 1; + if (etcp_create_server_socket(instance, server, &socket) < 0 || !socket) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload cannot create endpoint %s; new config retained for retry", server->name); + socket_result = -1; continue; + } + if (ncd_add_socket_links(instance, socket) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload endpoint %s ready, but some NCD links failed", server->name); socket_result = -1; + } + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "reload endpoint created name=%s tcp=%u id=%u generation=%llu", socket->name, + socket->is_tcp, socket->sock_id, (unsigned long long)socket->generation); } - os = next; + instance->socket_init_status = socket_result; } - int client_result = instance->core_started ? init_client_connections(instance, new_config->clients) : 0; - if (client_result < 0) - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload: failed to replace client handles; previous connections retained"); - // Reload networks: clear and repopulate - if (instance->networks) { - struct ll_entry *entry; - while ((entry = queue_data_get(instance->networks)) != NULL) { - queue_entry_free(entry); - } - struct CFG_NETWORK *net = new_config->networks; - while (net) { - struct ll_entry *new_entry = queue_entry_new(sizeof(struct NETWORK_ENTRY)); - if (!new_entry) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Failed to allocate network entry for reload: %s", net->name); - net = net->next; - continue; - } - struct NETWORK_ENTRY *ne = (struct NETWORK_ENTRY*)new_entry->data; - ne->id = net->id & 0x00FFFFFFFFFFFFFFULL; - for (int i = 0; i < SC_PUBKEY_SIZE; i++) { - unsigned int b; sscanf(net->pubkey_hex + i * 2, "%2x", &b); ne->pubkey[i] = (uint8_t)b; - } - for (int i = 0; i < SC_PRIVKEY_SIZE; i++) { - unsigned int b; sscanf(net->signing_key_hex + i * 2, "%2x", &b); ne->signing_key[i] = (uint8_t)b; - } - strncpy(ne->name, net->name, sizeof(ne->name) - 1); - ne->name[sizeof(ne->name) - 1] = '\0'; - queue_data_put_with_index(instance->networks, new_entry); - net = net->next; - } - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Networks reloaded"); - } - fw_free(&instance->fw); - instance->fw = next_fw; - DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Firewall reload applied: rules=%d bypass_all=%d", instance->fw.count, instance->fw.bypass_all); - // routing_destroy(instance); // commented per request - BGP self-restarts on conn events - // routing_create(instance); // commented per request + int clients_changed = servers_changed || !instance_clients_equal(previous->clients, next->clients); + int client_result = instance->core_started && clients_changed ? init_client_connections(instance, next->clients) : 0; stun_nat_destroy(instance); - if (instance->config) free_config(instance->config); - instance->config = new_config; - if (instance->core_started && stun_nat_init(instance) < 0) DEBUG_WARN(DEBUG_CATEGORY_NAT, "STUN restart failed after config reload"); - if (client_result < 0) DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "Reload completed with client errors; previous client handles retained"); - else DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Selective partial reload completed (only changed updated, unchanged untouched, [debug] applied)"); + instance->config = next; + free_config(previous); + queue_clear(instance->networks); queue_free(instance->networks); instance->networks = networks; + fw_free(&instance->fw); instance->fw = firewall; + if (servers_changed) { + if (instance->topo_groups && topo_node_update_my_addresses(instance) < 0) + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload could not publish local endpoint snapshot"); + for (struct ll_entry* e = instance->topo_groups ? instance->topo_groups->group_list->head : NULL; e; e = e->next) + conn_mgr_network_changed(((struct TOPO_GROUP*)e)->conn_mgr); + } + if (instance->core_started && stun_nat_init(instance) < 0) DEBUG_WARN(DEBUG_CATEGORY_NAT, "STUN restart failed after reload"); + if (socket_result < 0 || client_result < 0) + DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "partial reload completed with errors: sockets=%d clients=%d", socket_result, client_result); + else DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "partial reload completed: endpoints_changed=%d clients_changed=%d", servers_changed, clients_changed); return instance; } From 4e32de8e65313e618ed587b58a5914feb7e629be Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:11:24 +0100 Subject: [PATCH 22/45] Drain instance-owned sockets and registries without saved callback neighbors --- src/utun_instance.c | 50 +++++++++++++++++++++++++-------------------- src/utun_instance.h | 1 + 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index cc439427..6d5f1b89 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -424,6 +424,8 @@ struct UTUN_INSTANCE* utun_instance_create_from_str(struct UASYNC* ua, const cha // Destroy instance and cleanup resources static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { if (!instance) return; + if (instance->destroying) { DEBUG_WARN(DEBUG_CATEGORY_SYS, "recursive instance destroy ignored"); return; } + instance->destroying = 1; DEBUG_INFO(DEBUG_CATEGORY_SYS, "[INSTANCE_DESTROY] Starting cleanup for instance %p node=0x%016llx ua=%p", instance, (unsigned long long)instance->node_id, instance->ua); @@ -433,6 +435,7 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { if (instance->ua) uasync_print_resources(instance->ua, "INSTANCE_DESTROY_BEFORE"); instance->running = 0; + instance->core_started = 0; stun_nat_destroy(instance); stun_nat_forget_ports(instance); /* Оба детектора остановить до callbacks закрываемых сервисов и транспорта. */ @@ -491,15 +494,11 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { /* Phase G: ETCP sockets */ { - struct ETCP_SOCKET* sock = instance->etcp_sockets; int sc = 0; - while (sock) { - struct ETCP_SOCKET* next = sock->next; - if (sock->instance && sock->fd > 0) DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] G: removing socket %p fd=%d inst=%p", sock, sock->fd, sock->instance); - else if (sock->fd > 0) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "[DESTROY] G: socket %p fd=%d has NULL instance! skip remove", sock, sock->fd); sock = next; continue; } - etcp_socket_remove(sock); - sc++; - sock = next; + while (instance->etcp_sockets) { + struct ETCP_SOCKET* socket = instance->etcp_sockets; + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "destroy endpoint name=%s tcp=%u id=%u", socket->name, socket->is_tcp, socket->sock_id); + instance_remove_socket(instance, socket); sc++; } if (sc) DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] G: removed %d ETCP sockets", sc); } @@ -512,15 +511,14 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { /* Phase J: TCP connections + sockets — MUST be before ETCP connections */ stcp_server_list_destroy_all(instance); - reality_owner_cleanup(&instance->reality_owner); /* активные reality-релеи откладывают u_free через call_soon */ + reality_owner_cleanup(&instance->reality_owner); // синхронно освобождает активные/закрытые relay и replay-кеш if (instance->tcp_connections) { - struct ll_entry* entry = instance->tcp_connections->head; int tc = 0; - while (entry) { - struct ll_entry* next = entry->next; + struct ll_entry* entry; + while ((entry = queue_data_get(instance->tcp_connections)) != NULL) { struct tcp_conn_entry* te = (struct tcp_conn_entry*)entry->data; - if (te && te->link) { stcp_link_close(te->link); tc++; } - entry = next; + struct stcp_link* link = te->link; queue_entry_free(entry); + if (link) { stcp_link_close(link); tc++; } } if (tc) DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] J: closed %d TCP links", tc); } @@ -531,13 +529,15 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { /* Phase K: ETCP connections */ { - struct ll_entry* entry = instance->connections ? instance->connections->head : NULL; int cc = 0; - while (entry) { - struct ll_entry* next = entry->next; + while (instance->connections && instance->connections->head) { + struct ll_entry* entry = instance->connections->head; struct conn_queue_entry* ce = (struct conn_queue_entry*)entry->data; - if (ce && ce->conn) { etcp_connection_close(ce->conn); cc++; } - entry = next; + if (ce->conn) { etcp_connection_close(ce->conn); cc++; } + else { + DEBUG_WARN(DEBUG_CATEGORY_SYS, "destroy: empty connection registry entry"); + queue_remove_data(instance->connections, entry); queue_entry_free(entry); + } } if (cc) DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] K: closed %d ETCP connections", cc); } @@ -644,7 +644,13 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { // Shared SQLite DB закрываем в самом конце: routing/chat/media/db_sync // используют его во время всего teardown (см. topo_groups_destroy). if (instance->topo_sqlite_db) { - sqlite3_close(instance->topo_sqlite_db); + int rc = sqlite3_close(instance->topo_sqlite_db); + if (rc != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "shared sqlite close failed rc=%d: %s; deferring outstanding handles", rc, + sqlite3_errmsg(instance->topo_sqlite_db)); + rc = sqlite3_close_v2(instance->topo_sqlite_db); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_SYS, "shared sqlite deferred close failed rc=%d", rc); + } instance->topo_sqlite_db = NULL; DEBUG_INFO(DEBUG_CATEGORY_SYS, "[INSTANCE_DESTROY] shared sqlite closed"); } @@ -688,7 +694,7 @@ static int mkdir_recursive(const char *path) { } int utun_core_start(struct UTUN_INSTANCE *instance) { - if (!instance) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "core start: NULL instance"); return -1; } + if (!instance || instance->destroying) { DEBUG_ERROR(DEBUG_CATEGORY_SYS, "core start: missing or destroying instance"); return -1; } if (instance->core_started) return 0; struct utun_config* config = instance->config; @@ -1068,7 +1074,7 @@ static int instance_clients_equal(const struct CFG_CLIENT* a, const struct CFG_C /* Готовит конфиг/firewall/индекс до мутаций. Изменения владельцев требуют полного restart; * ручные endpoint обновляются поимённо, без сравнения port=0 с фактическим портом ОС. */ struct UTUN_INSTANCE* utun_instance_reload(struct UTUN_INSTANCE* instance, struct UASYNC* ua, const char* config_file) { - if (!instance || !ua || ua != instance->ua || !config_file || !*config_file) { + if (!instance || instance->destroying || !ua || ua != instance->ua || !config_file || !*config_file) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "reload requires instance, its owning uasync and a config path"); return instance; } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "reload preparing %s", config_file); diff --git a/src/utun_instance.h b/src/utun_instance.h index e42de737..2ec74fa0 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -134,6 +134,7 @@ struct UTUN_INSTANCE { struct etcp_tx_history tx_history; struct reality_owner reality_owner; uint8_t core_started, utun_started, chat_started; // успешный запуск ядра / UTUN / чата + uint8_t destroying; // запрещает повторный запуск ядра/повторное destroy во время очистки // Identification char name[MAX_CONN_NAME_LEN]; // Instance name from config From 9992228dcb6226c9f049bb8cd173acab098535b5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:12:48 +0100 Subject: [PATCH 23/45] Cover instance callbacks, constructor failures and selective/full reload --- tests/Makefile.am | 4 + tests/test_instance_contract.c | 159 +++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/test_instance_contract.c diff --git a/tests/Makefile.am b/tests/Makefile.am index 60cca3e3..0b948f57 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -287,6 +287,10 @@ test_socket_monitor_LDFLAGS = -Wl,--wrap=socket -Wl,--wrap=bind -Wl,--wrap=close if OS_LINUX test_socket_monitor_LDFLAGS += -Wl,--wrap=recvfrom -Wl,--wrap=__recvfrom_chk check_PROGRAMS += test_config_parser +check_PROGRAMS += test_instance_contract +test_instance_contract_SOURCES = test_instance_contract.c +test_instance_contract_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_instance_contract_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=etcp_create_server_socket test_config_parser_SOURCES = test_config_parser.c test_config_parser_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_config_parser_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c new file mode 100644 index 00000000..f8c6e131 --- /dev/null +++ b/tests/test_instance_contract.c @@ -0,0 +1,159 @@ +/* Контракт instance: вложенные подписки, конструкторы, selective/full reload и владение ресурсами. */ +#include "utun_instance.h" +#include "transport_layer/etcp_connections.h" +#include "chat/chat_core.h" +#include "chat/chat_headless_control.h" +#include "call/call_headless.h" +#include "radio/radio_headless.h" +#include "../lib/mem.h" +#include "../lib/debug_config.h" +#include "../lib/platform_compat.h" +#include +#include +#include +#include + +static int fail_subscription, fail_queue, fail_endpoint; +extern void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); +void* __wrap_u_calloc_impl(uint32_t count, uint32_t size, const char* where) { + if (fail_subscription && strstr(where, "utun_instance.c")) { fail_subscription = 0; return NULL; } + if (fail_queue && strstr(where, "ll_queue.c")) { fail_queue = 0; return NULL; } + return __real_u_calloc_impl(count, size, where); +} +extern int __real_etcp_create_server_socket(struct UTUN_INSTANCE*, struct CFG_SERVER*, struct ETCP_SOCKET**); +int __wrap_etcp_create_server_socket(struct UTUN_INSTANCE* inst, struct CFG_SERVER* cfg, struct ETCP_SOCKET** out) { + if (fail_endpoint) { + fail_endpoint = 0; *out = NULL; + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "injected listener failure name=%s", cfg->name); return -1; + } + return __real_etcp_create_server_socket(inst, cfg, out); +} + +static int activity_a, activity_b, activity_c, activity_d, sleep_a, sleep_b, sleep_c, sleep_d; +static void on_a(struct UTUN_INSTANCE* inst, int active, void* arg) { (void)inst; (void)arg; assert(!active); activity_a++; } +static void on_b(struct UTUN_INSTANCE* inst, int active, void* arg) { (void)inst; (void)active; (void)arg; activity_b++; } +static void on_d(struct UTUN_INSTANCE* inst, int active, void* arg) { (void)inst; (void)arg; assert(!active); activity_d++; } +static void on_c(struct UTUN_INSTANCE* inst, int active, void* arg) { + (void)arg; assert(active); activity_c++; + utun_remove_activity_cbk(inst, on_b, NULL); utun_remove_activity_cbk(inst, on_c, NULL); + utun_add_activity_cbk(inst, on_d, NULL); assert(!utun_set_client_activity(inst, 0)); +} +static void sleeping_a(struct UTUN_INSTANCE* inst, uint64_t node, int sleeping, void* arg) { + (void)inst; (void)arg; assert((node == 1 || node == 2) && sleeping); sleep_a++; +} +static void sleeping_b(struct UTUN_INSTANCE* inst, uint64_t node, int sleeping, void* arg) { + (void)inst; (void)node; (void)sleeping; (void)arg; sleep_b++; +} +static void sleeping_d(struct UTUN_INSTANCE* inst, uint64_t node, int sleeping, void* arg) { + (void)inst; (void)arg; assert(node == 2 && sleeping); sleep_d++; +} +static void sleeping_c(struct UTUN_INSTANCE* inst, uint64_t node, int sleeping, void* arg) { + (void)arg; assert(node == 1 && sleeping); sleep_c++; + utun_remove_peer_sleep_cbk(inst, sleeping_b, NULL); utun_remove_peer_sleep_cbk(inst, sleeping_c, NULL); + utun_add_peer_sleep_cbk(inst, sleeping_d, NULL); utun_fire_peer_sleep_cbk(inst, 2, 1); +} + +static void test_callbacks(void) { + size_t baseline = u_get_allocated_count(); + struct UTUN_INSTANCE inst = {.client_type = CLIENT_TYPE_DESKTOP}; + utun_add_activity_cbk(&inst, on_a, NULL); utun_add_activity_cbk(&inst, on_a, NULL); + utun_add_activity_cbk(&inst, on_b, NULL); utun_add_activity_cbk(&inst, on_c, NULL); + assert(!utun_set_client_activity(&inst, 1)); + assert(activity_a == 1 && !activity_b && activity_c == 1 && activity_d == 1 && !inst.lifecycle_callbacks_depth); + utun_remove_activity_cbk(&inst, on_a, NULL); utun_remove_activity_cbk(&inst, on_d, NULL); + utun_add_peer_sleep_cbk(&inst, sleeping_a, NULL); utun_add_peer_sleep_cbk(&inst, sleeping_a, NULL); + utun_add_peer_sleep_cbk(&inst, sleeping_b, NULL); utun_add_peer_sleep_cbk(&inst, sleeping_c, NULL); + utun_fire_peer_sleep_cbk(&inst, 1, 1); + assert(sleep_a == 2 && !sleep_b && sleep_c == 1 && sleep_d == 1 && !inst.lifecycle_callbacks_depth); + utun_remove_peer_sleep_cbk(&inst, sleeping_a, NULL); utun_remove_peer_sleep_cbk(&inst, sleeping_d, NULL); + fail_subscription = 1; utun_add_activity_cbk(&inst, on_a, NULL); assert(!fail_subscription && !inst.activity_cbks); + fail_subscription = 1; utun_add_peer_sleep_cbk(&inst, sleeping_a, NULL); assert(!fail_subscription && !inst.peer_sleep_cbks); + assert(u_get_allocated_count() == baseline); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS nested callbacks: remove self/next, duplicate add, newer activity and subscription faults"); +} + +static const char* private_key = "704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f"; +static const char* public_key = "b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01"; + +static void write_config(const char* path, const char* name, const char* servers, const char* extras) { + FILE* file = fopen(path, "wb"); assert(file); + assert(fprintf(file, "[global]\nmy_node_name=%s\nmy_private_key=%s\nmy_public_key=%s\n%s%s", + name, private_key, public_key, servers, extras) > 0 && !fclose(file)); +} +static struct ETCP_SOCKET* endpoint(struct UTUN_INSTANCE* inst, const char* name) { + for (struct ETCP_SOCKET* s = inst->etcp_sockets; s; s = s->next) if (!strcmp(s->name, name)) return s; + return NULL; +} +static unsigned endpoint_count(struct UTUN_INSTANCE* inst) { + unsigned count = 0; for (struct ETCP_SOCKET* s = inst->etcp_sockets; s; s = s->next) count++; return count; +} + +static void test_creation(struct UASYNC* ua) { + size_t baseline = u_get_allocated_count(); + assert(!utun_instance_create(NULL, "/tmp/utun-must-not-create-invalid-owner")); + assert(!utun_instance_create_from_str(NULL, "[global]\n")); + struct utun_config* config = parse_config_from_buf("", 0, "caller-config"); assert(config); + assert(!utun_instance_create_from_config(NULL, config)); + assert(!utun_instance_create_from_config(ua, config)); // не задана идентичность, но config остаётся caller + assert(config->global.mtu == 1280); free_config(config); + assert(u_get_allocated_count() == baseline); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS constructor invalid owner, partial teardown and retained caller config"); +} + +static void test_reload(struct UASYNC* ua) { + size_t baseline = u_get_allocated_count(); + char path[] = "/tmp/utun-instance-contract-XXXXXX"; + int fd = mkstemp(path); assert(fd >= 0); close(fd); + const char* first = "[server:udp]\naddr=127.0.0.1:0\ntype=private\n" + "[server:tcp]\naddr=127.0.0.1:0\ntype=private\ntransport=tcp\n"; + write_config(path, "same", first, ""); + struct UTUN_INSTANCE* inst = utun_instance_create(ua, path); assert(inst && !utun_core_start(inst)); + struct ETCP_CONN marker = {0}; + struct ll_entry* entry = queue_entry_new(sizeof(struct tcp_conn_entry)); assert(entry); + struct tcp_conn_entry* tcp = (struct tcp_conn_entry*)entry->data; tcp->node_id = 123; tcp->etcp_conn = ▮ + assert(!queue_data_put_with_index(inst->tcp_connections, entry) && instance_find_conn(inst, 123) == &marker); + queue_remove_data(inst->tcp_connections, entry); queue_entry_free(entry); + uint64_t udp_generation = endpoint(inst, "udp")->generation, tcp_generation = endpoint(inst, "tcp")->generation; + uint16_t udp_port = ss_get_port(&endpoint(inst, "udp")->local_addr); + struct UTUN_INSTANCE* original = inst; + assert(utun_instance_reload(inst, ua, path) == original); + assert(endpoint(inst, "udp")->generation == udp_generation && endpoint(inst, "tcp")->generation == tcp_generation); + assert(ss_get_port(&endpoint(inst, "udp")->local_addr) == udp_port && endpoint_count(inst) == 2); + write_config(path, "same", first, "[global]\nmtu=bad\n"); + assert(utun_instance_reload(inst, ua, path) == inst && endpoint(inst, "udp")->generation == udp_generation); + write_config(path, "same", first, ""); + fail_queue = 1; + assert(utun_instance_reload(inst, ua, path) == inst && !fail_queue && endpoint(inst, "udp")->generation == udp_generation); + const char* changed = "[server:udp]\naddr=127.0.0.2:0\ntype=private\n" + "[server:tcp]\naddr=127.0.0.1:0\ntype=private\ntransport=tcp\n"; + write_config(path, "same", changed, ""); fail_endpoint = 1; + assert(utun_instance_reload(inst, ua, path) == inst && !fail_endpoint && !endpoint(inst, "udp")); + assert(endpoint(inst, "tcp")->generation == tcp_generation && stcp_server_list_count(inst) == 1); + assert(utun_instance_reload(inst, ua, path) == inst && endpoint_count(inst) == 2); + assert(endpoint(inst, "udp")->generation != udp_generation && endpoint(inst, "tcp")->generation == tcp_generation); + const char* one = "[server:tcp]\naddr=127.0.0.1:0\ntype=private\ntransport=udp\n"; + write_config(path, "same", one, ""); + assert(utun_instance_reload(inst, ua, path) == inst && endpoint_count(inst) == 1); + assert(!endpoint(inst, "tcp")->is_tcp && !stcp_server_list_count(inst)); + sqlite3_stmt* statement = NULL; + assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT 1", -1, &statement, NULL) == SQLITE_OK); + utun_instance_stop(inst); assert(!inst->running); + write_config(path, "new name", first, ""); + inst = utun_instance_reload(inst, ua, path); assert(inst && inst->core_started && !inst->utun_started && !inst->chat_started); + assert(!strcmp(inst->name, "new name") && !inst->running && endpoint_count(inst) == 2 && stcp_server_list_count(inst) == 1); + assert(sqlite3_finalize(statement) == SQLITE_OK); + struct UASYNC wrong_owner = {0}; assert(utun_instance_reload(inst, &wrong_owner, path) == inst); + utun_instance_destroy(inst); uasync_drain_immediate(ua); unlink(path); + assert(u_get_allocated_count() == baseline); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS reload: ephemeral stability, malformed config, queue/create faults, TCP change/delete and restart state"); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); + test_callbacks(); + struct UASYNC* ua = uasync_create(); assert(ua); + test_creation(ua); test_reload(ua); + assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); + uasync_destroy(ua, 0); return 0; +} From 88801318bbb381f639699284f5012cba6a4ed685 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:16:31 +0100 Subject: [PATCH 24/45] Include complete ETCP connection type in instance contract test --- tests/test_instance_contract.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index f8c6e131..aa68a3c2 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -1,5 +1,6 @@ /* Контракт instance: вложенные подписки, конструкторы, selective/full reload и владение ресурсами. */ #include "utun_instance.h" +#include "transport_layer/etcp.h" #include "transport_layer/etcp_connections.h" #include "chat/chat_core.h" #include "chat/chat_headless_control.h" From 234c21fb6a1204d4bd822d52e8464c7a847ad374 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:17:35 +0100 Subject: [PATCH 25/45] Report outstanding allocations before instance teardown assertion --- tests/test_instance_contract.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index aa68a3c2..18e4f92c 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -145,6 +145,10 @@ static void test_reload(struct UASYNC* ua) { assert(sqlite3_finalize(statement) == SQLITE_OK); struct UASYNC wrong_owner = {0}; assert(utun_instance_reload(inst, &wrong_owner, path) == inst); utun_instance_destroy(inst); uasync_drain_immediate(ua); unlink(path); + if (u_get_allocated_count() != baseline) { + DEBUG_ERROR(DEBUG_CATEGORY_SYS, "instance teardown allocations baseline=%zu actual=%zu", baseline, u_get_allocated_count()); + u_report_unfreed_blocks(); + } assert(u_get_allocated_count() == baseline); DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS reload: ephemeral stability, malformed config, queue/create faults, TCP change/delete and restart state"); } From 1d9259178048e16f292e67114b425b785ece1cfb Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:18:40 +0100 Subject: [PATCH 26/45] Validate headless bindings and honor ephemeral ports in chat startup --- src/utun_instance.c | 66 ++++++++++++++-------------------- tests/test_instance_contract.c | 34 +++++++++++++----- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index 6d5f1b89..d3c27a79 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -833,46 +833,34 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { dm_media_init(instance) < 0 || call_init(instance) < 0 || radio_call_init(instance) < 0) goto fail; chat_media_backfill(instance); chat_media_startup_backfill_init(instance); - // Initialize headless chat control if configured in [chatserver] - if (instance->config->global.headless_control_bind[0] != '\0') { - char ip_str[64] = "127.0.0.1"; int port = 9999; - const char* colon = strrchr(instance->config->global.headless_control_bind, ':'); - if (colon) { - size_t ip_len = (size_t)(colon - instance->config->global.headless_control_bind); - if (ip_len < sizeof(ip_str)) { memcpy(ip_str, instance->config->global.headless_control_bind, ip_len); ip_str[ip_len] = '\0'; } - port = atoi(colon + 1); - if (port <= 0 || port > 65535) port = 9999; + /* Все headless listeners используют IPv4:port; port=0 выбирает ОС. Ошибка отключает только этот listener. */ + const struct { + const char* bind; + const char* name; + int (*start)(struct UASYNC*, struct UTUN_INSTANCE*, const char*, int); + } headless[] = { + {instance->config->global.headless_control_bind, "chat control", chat_headless_control_init}, + {instance->config->global.call_audio_bind, "call audio", call_headless_init}, + {instance->config->global.radio_audio_bind, "radio audio", radio_headless_init} + }; + for (size_t i = 0; i < sizeof(headless) / sizeof(headless[0]); i++) { + const char* bind = headless[i].bind; + if (!*bind) continue; + const char* colon = strrchr(bind, ':'); + char ip[INET_ADDRSTRLEN]; struct in_addr address; + size_t length = colon ? (size_t)(colon - bind) : 0; + unsigned port = 0; int valid = colon && colon[1] && length > 0 && length < sizeof(ip); + if (valid) { + memcpy(ip, bind, length); ip[length] = '\0'; + valid = inet_pton(AF_INET, ip, &address) == 1; + for (const char* p = colon + 1; valid && *p; p++) { + if (*p < '0' || *p > '9' || port > (65535U - (unsigned)(*p - '0')) / 10) valid = 0; + else port = port * 10 + (unsigned)(*p - '0'); + } } - if (chat_headless_control_init(instance->ua, instance, ip_str, port) != 0) - DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless chat control (non-fatal)"); - } - - // Initialize headless call audio socket if configured in [chatserver] - if (instance->config->global.call_audio_bind[0] != '\0') { - char ip_str[64] = "127.0.0.1"; int port = 10001; - const char* colon = strrchr(instance->config->global.call_audio_bind, ':'); - if (colon) { - size_t ip_len = (size_t)(colon - instance->config->global.call_audio_bind); - if (ip_len < sizeof(ip_str)) { memcpy(ip_str, instance->config->global.call_audio_bind, ip_len); ip_str[ip_len] = '\0'; } - port = atoi(colon + 1); - if (port <= 0 || port > 65535) port = 10001; - } - if (call_headless_init(instance->ua, instance, ip_str, port) != 0) - DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless call audio socket (non-fatal)"); - } - - // Initialize headless radio audio socket if configured in [chatserver] - if (instance->config->global.radio_audio_bind[0] != '\0') { - char ip_str[64] = "127.0.0.1"; int port = 10002; - const char* colon = strrchr(instance->config->global.radio_audio_bind, ':'); - if (colon) { - size_t ip_len = (size_t)(colon - instance->config->global.radio_audio_bind); - if (ip_len < sizeof(ip_str)) { memcpy(ip_str, instance->config->global.radio_audio_bind, ip_len); ip_str[ip_len] = '\0'; } - port = atoi(colon + 1); - if (port <= 0 || port > 65535) port = 10002; - } - if (radio_headless_init(instance->ua, instance, ip_str, port) != 0) - DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to init headless radio audio socket (non-fatal)"); + if (!valid) { DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "headless %s disabled: invalid IPv4:port '%s'", headless[i].name, bind); continue; } + if (headless[i].start(instance->ua, instance, ip, (int)port) != 0) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "headless %s listener failed bind=%s (non-fatal)", headless[i].name, bind); } diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index 18e4f92c..d94d597f 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -102,7 +102,6 @@ static void test_creation(struct UASYNC* ua) { } static void test_reload(struct UASYNC* ua) { - size_t baseline = u_get_allocated_count(); char path[] = "/tmp/utun-instance-contract-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0); close(fd); const char* first = "[server:udp]\naddr=127.0.0.1:0\ntype=private\n" @@ -145,20 +144,39 @@ static void test_reload(struct UASYNC* ua) { assert(sqlite3_finalize(statement) == SQLITE_OK); struct UASYNC wrong_owner = {0}; assert(utun_instance_reload(inst, &wrong_owner, path) == inst); utun_instance_destroy(inst); uasync_drain_immediate(ua); unlink(path); - if (u_get_allocated_count() != baseline) { - DEBUG_ERROR(DEBUG_CATEGORY_SYS, "instance teardown allocations baseline=%zu actual=%zu", baseline, u_get_allocated_count()); - u_report_unfreed_blocks(); - } - assert(u_get_allocated_count() == baseline); DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS reload: ephemeral stability, malformed config, queue/create faults, TCP change/delete and restart state"); } +static void test_headless(struct UASYNC* ua) { + char text[1024]; + snprintf(text, sizeof(text), "[global]\nmy_private_key=%s\nmy_public_key=%s\nclient_type=server\n" + "[chatserver]\nheadless_control_bind=127.0.0.1:0\ncall_audio_bind=127.0.0.1:0\nradio_audio_bind=127.0.0.1:0\n", + private_key, public_key); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, text); assert(inst && !utun_core_start(inst)); + assert(!chat_service_start(inst) && !inst->utun_started); + assert(chat_headless_control_get_port(inst) && call_headless_get_port(inst) && radio_headless_get_port(inst)); + sqlite3* db = inst->topo_sqlite_db; + chat_service_stop(inst); + assert(inst->core_started && inst->topo_sqlite_db == db && !inst->chat_started); + assert(!chat_headless_control_get_port(inst) && !call_headless_get_port(inst) && !radio_headless_get_port(inst)); + strcpy(inst->config->global.headless_control_bind, "127.0.0.1:garbage"); + strcpy(inst->config->global.call_audio_bind, "127.0.0.1:65536"); + strcpy(inst->config->global.radio_audio_bind, "127.0.0.1"); + assert(!chat_service_start(inst)); + assert(!chat_headless_control_get_port(inst) && !call_headless_get_port(inst) && !radio_headless_get_port(inst)); + utun_instance_destroy(inst); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS chat-only lifecycle: three ephemeral headless ports, retained SQLite and malformed bind rejection"); +} + int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); test_callbacks(); + size_t baseline = u_get_allocated_count(); struct UASYNC* ua = uasync_create(); assert(ua); - test_creation(ua); test_reload(ua); + test_creation(ua); test_reload(ua); test_headless(ua); assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); - uasync_destroy(ua, 0); return 0; + uasync_destroy(ua, 0); // кэш таймеров принадлежит UASYNC, освобождается вместе с ним + if (u_get_allocated_count() != baseline) u_report_unfreed_blocks(); + assert(u_get_allocated_count() == baseline); return 0; } From fd93f39c780352e4e6a6336f4e72797e722edda8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:52:08 +0100 Subject: [PATCH 27/45] Release remaining transport subscriptions at instance destruction --- src/utun_instance.c | 17 +++++++++++++++++ tests/test_instance_contract.c | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index d3c27a79..8cb4bf9d 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -624,6 +624,23 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { DEBUG_INFO(DEBUG_CATEGORY_SYS, "[INSTANCE_DESTROY] Freeing instance memory"); // Cleanup nodeinfo callback chain + while (instance->new_conn_cbks) { + struct etcp_inst_cbk_entry* entry = instance->new_conn_cbks; + instance->new_conn_cbks = entry->next; u_free(entry); + } + while (instance->link_status_cbks) { + struct etcp_link_status_cbk_entry* entry = instance->link_status_cbks; + instance->link_status_cbks = entry->next; u_free(entry); + } + while (instance->conn_status_cbks) { + struct etcp_status_cbk_entry* entry = instance->conn_status_cbks; + instance->conn_status_cbks = entry->next; u_free(entry); + } + while (instance->socket_cbks) { + struct etcp_socket_cbk_entry* entry = instance->socket_cbks; + instance->socket_cbks = entry->next; u_free(entry); + } + DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "instance transport subscriptions released"); while (instance->nodeinfo_cbks) { struct nodeinfo_cbk_entry* r = instance->nodeinfo_cbks; instance->nodeinfo_cbks = r->next; u_free(r); diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index d94d597f..a457b906 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -149,7 +149,7 @@ static void test_reload(struct UASYNC* ua) { static void test_headless(struct UASYNC* ua) { char text[1024]; - snprintf(text, sizeof(text), "[global]\nmy_private_key=%s\nmy_public_key=%s\nclient_type=server\n" + snprintf(text, sizeof(text), "[global]\nmy_private_key=%s\nmy_public_key=%s\nclient_type=0\n" "[chatserver]\nheadless_control_bind=127.0.0.1:0\ncall_audio_bind=127.0.0.1:0\nradio_audio_bind=127.0.0.1:0\n", private_key, public_key); struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, text); assert(inst && !utun_core_start(inst)); From f5488adab0a24c965abf67c48262af69e20b37f0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:52:50 +0100 Subject: [PATCH 28/45] Release headless audio contexts, clients and timers on chat stop --- src/call/call_headless.c | 8 ++++++-- src/radio/radio_headless.c | 19 +++++++++++++++---- src/utun_instance.c | 11 +++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/call/call_headless.c b/src/call/call_headless.c index 410b15d5..1dbb4994 100644 --- a/src/call/call_headless.c +++ b/src/call/call_headless.c @@ -346,7 +346,10 @@ int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, call_set_media_recv_cb(inst, ca_media_recv, ca); ca->cleanup_timer = uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup"); - if (!ca->cleanup_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID); + if (!ca->cleanup_timer) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID); + call_headless_destroy(inst); return -1; + } ca->running = 1; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket listening on %s:%u", CH_ID, bind_ip, bound_port); @@ -361,7 +364,7 @@ uint16_t call_headless_get_port(struct UTUN_INSTANCE* inst) { void call_headless_destroy(struct UTUN_INSTANCE* inst) { struct call_audio* ca = ca_of(inst); - if (!ca || !ca->running) return; + if (!ca) return; ca->running = 0; if (ca->cleanup_timer) { uasync_cancel_timeout(ca->ua, ca->cleanup_timer); ca->cleanup_timer = NULL; } call_set_media_recv_cb(inst, NULL, NULL); @@ -379,4 +382,5 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) { ca->clients = NULL; ca->client_count = 0; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)", CH_ID, ca->c_enc_frames, ca->c_dec_frames, ca->c_enc_dropped, ca->c_dec_dropped); + inst->call_audio = NULL; u_free(ca); } diff --git a/src/radio/radio_headless.c b/src/radio/radio_headless.c index 3153df0e..9231b35d 100644 --- a/src/radio/radio_headless.c +++ b/src/radio/radio_headless.c @@ -82,6 +82,7 @@ struct radio_headless { opus_codec_encoder_t* enc; /* TX-кодер активного канала */ struct rh_source sources[RH_MAX_SOURCES]; void* mix_timer; + void* cleanup_timer; // отменяется вместе с mix_timer до освобождения контекста int32_t mix[RH_FRAME_SAMPLES]; /* диагностика */ uint32_t c_enc_frames, c_dec_frames, c_enc_dropped, c_dec_dropped; @@ -294,6 +295,7 @@ static void rh_mix_tick(void* arg) { } rh->mix_timer = uasync_set_timeout(rh->ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); + if (!rh->mix_timer) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: mix timer scheduling failed", RH_ID); } /* ── разбор входящих фреймов ── */ @@ -431,6 +433,8 @@ static void rh_accept_callback(socket_t fd, void* arg) { static void rh_cleanup(void* arg) { struct radio_headless* rh = (struct radio_headless*)arg; + rh->cleanup_timer = NULL; + if (!rh->running) return; struct radio_headless_client** prev = &rh->clients; while (*prev) { if ((*prev)->closing) { @@ -439,7 +443,8 @@ static void rh_cleanup(void* arg) { u_free(dead); rh->client_count--; } else prev = &(*prev)->next; } - if (rh->ua) uasync_set_timeout(rh->ua, 5000, rh, rh_cleanup, "rh_cleanup"); + rh->cleanup_timer = uasync_set_timeout(rh->ua, 5000, rh, rh_cleanup, "rh_cleanup"); + if (!rh->cleanup_timer) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: cleanup timer scheduling failed", RH_ID); } /* ── init / destroy ── */ @@ -480,8 +485,12 @@ int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, radio_set_frame_cb(inst, rh_frame_cb, rh); radio_set_mute_cb(inst, rh_member_mute, rh); - uasync_set_timeout(ua, 5000, rh, rh_cleanup, "rh_cleanup"); + rh->cleanup_timer = uasync_set_timeout(ua, 5000, rh, rh_cleanup, "rh_cleanup"); rh->mix_timer = uasync_set_timeout(ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); + if (!rh->cleanup_timer || !rh->mix_timer) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: startup timer scheduling failed cleanup=%p mix=%p", RH_ID, rh->cleanup_timer, rh->mix_timer); + radio_headless_destroy(inst); return -1; + } rh->running = 1; DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket listening on %s:%u", RH_ID, bind_ip, bound_port); @@ -496,11 +505,12 @@ uint16_t radio_headless_get_port(struct UTUN_INSTANCE* inst) { void radio_headless_destroy(struct UTUN_INSTANCE* inst) { struct radio_headless* rh = rh_of(inst); - if (!rh || !rh->running) return; + if (!rh) return; rh->running = 0; radio_set_frame_cb(inst, NULL, NULL); radio_set_mute_cb(inst, NULL, NULL); if (rh->mix_timer && rh->ua) { uasync_cancel_timeout(rh->ua, rh->mix_timer); rh->mix_timer = NULL; } + if (rh->cleanup_timer && rh->ua) { uasync_cancel_timeout(rh->ua, rh->cleanup_timer); rh->cleanup_timer = NULL; } if (rh->listen_sock_id && rh->ua) uasync_remove_socket_t(rh->ua, rh->listen_fd); if (rh->listen_fd != SOCKET_INVALID) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; } if (rh->enc) { opus_codec_encoder_destroy(rh->enc); rh->enc = NULL; } @@ -508,11 +518,12 @@ void radio_headless_destroy(struct UTUN_INSTANCE* inst) { struct radio_headless_client* c = rh->clients; while (c) { struct radio_headless_client* n = c->next; - if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd); + rh_close_client(c); u_free(c); c = n; } rh->clients = NULL; rh->client_count = 0; DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)", RH_ID, rh->c_enc_frames, rh->c_dec_frames, rh->c_enc_dropped, rh->c_dec_dropped); + inst->radio_headless = NULL; u_free(rh); } diff --git a/src/utun_instance.c b/src/utun_instance.c index 8cb4bf9d..41086941 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -855,10 +855,11 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { const char* bind; const char* name; int (*start)(struct UASYNC*, struct UTUN_INSTANCE*, const char*, int); + void (*stop)(struct UTUN_INSTANCE*); } headless[] = { - {instance->config->global.headless_control_bind, "chat control", chat_headless_control_init}, - {instance->config->global.call_audio_bind, "call audio", call_headless_init}, - {instance->config->global.radio_audio_bind, "radio audio", radio_headless_init} + {instance->config->global.headless_control_bind, "chat control", chat_headless_control_init, chat_headless_control_destroy}, + {instance->config->global.call_audio_bind, "call audio", call_headless_init, call_headless_destroy}, + {instance->config->global.radio_audio_bind, "radio audio", radio_headless_init, radio_headless_destroy} }; for (size_t i = 0; i < sizeof(headless) / sizeof(headless[0]); i++) { const char* bind = headless[i].bind; @@ -876,8 +877,10 @@ int chat_service_start(struct UTUN_INSTANCE* instance) { } } if (!valid) { DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "headless %s disabled: invalid IPv4:port '%s'", headless[i].name, bind); continue; } - if (headless[i].start(instance->ua, instance, ip, (int)port) != 0) + if (headless[i].start(instance->ua, instance, ip, (int)port) != 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "headless %s listener failed bind=%s (non-fatal)", headless[i].name, bind); + headless[i].stop(instance); + } } From 0d1461324404640dedda6b8099c87f1b9ff65cf6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:53:55 +0100 Subject: [PATCH 29/45] Document instance ownership, lifecycle, reload and callback contracts --- src/utun_instance.h | 95 ++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/src/utun_instance.h b/src/utun_instance.h index 2ec74fa0..39924b58 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -1,8 +1,9 @@ /* utun_instance — владелец ядра и сервисов одного узла. * Ядро содержит транспорт, маршрутизатор, идентичность и общую БД. * create → core_start; UTUN и чат запускаются отдельно через service_start/stop. - * Все операции жизненного цикла — в потоке переданного uasync, вне callbacks сервисов. - * destroy останавливает сервисы и освобождает ядро, но не сам uasync. */ + * Все операции — в потоке переданного UASYNC; stop/reload/destroy вне callbacks останавливаемых модулей. + * Цикл uasync_poll запускает вызывающий; один UASYNC можно разделять между несколькими instance. + * destroy останавливает сервисы и освобождает ядро, но не UASYNC. Подробности: utun_instance_doc.md. */ #ifndef UTUN_INSTANCE_H #define UTUN_INSTANCE_H @@ -34,8 +35,6 @@ extern "C" { #include "ntp_time.h" #include "ntp_node_time.h" -#include "etcp_api.h" -#include "config_parser.h" #include "topo_group.h" #include "chat/chat_setting.h" #include "chat/chat_event.h" @@ -77,16 +76,16 @@ struct STUN_NAT; struct merkle_sync; struct NETWORK_ENTRY { - uint64_t id; // 56-bit (offset 0 = index key) - uint8_t pubkey[SC_PUBKEY_SIZE]; // 32 bytes binary - uint8_t signing_key[SC_PRIVKEY_SIZE]; // 32 bytes binary - char name[64]; + uint64_t id; // 56-битный ID сети; первые 7 байт — ключ индекса + uint8_t pubkey[SC_PUBKEY_SIZE]; // бинарный публичный ключ сети + uint8_t signing_key[SC_PRIVKEY_SIZE]; // бинарный ключ подписи сети + char name[64]; // имя [network:name], ASCIIZ }; // Queue entry for instance->connections (indexed by peer_node_id) struct conn_queue_entry { - uint64_t peer_node_id; - struct ETCP_CONN* conn; + uint64_t peer_node_id; // ключ индекса, 0 до определения пира + struct ETCP_CONN* conn; // живой транспорт; удаляется при close }; /* Постоянный транспорт ядра к пиру [client]; UTUN приобретает отдельный групповой запрос. @@ -112,9 +111,9 @@ struct tcp_conn_entry { // подписчикам (topo_group, keepalive и т.д.) при каждом изменении активности. typedef void (*utun_activity_cbk_fn)(struct UTUN_INSTANCE* instance, int active, void* arg); struct utun_activity_cbk_entry { - utun_activity_cbk_fn fn; - void* arg; - struct utun_activity_cbk_entry* next; + utun_activity_cbk_fn fn; // синхронный обработчик эффективной активности + void* arg; // заимствованный контекст обработчика + struct utun_activity_cbk_entry* next; // следующая подписка uint8_t removed; // tombstone до выхода из внешней рассылки }; @@ -123,16 +122,16 @@ struct utun_activity_cbk_entry { // sleeping=1 — пир ушёл в спячку (в фон), 0 — проснулся (дедлайн истёк / обычный keepalive). typedef void (*peer_sleep_cbk_fn)(struct UTUN_INSTANCE* instance, uint64_t peer_node_id, int sleeping, void* arg); struct peer_sleep_cbk_entry { - peer_sleep_cbk_fn fn; - void* arg; - struct peer_sleep_cbk_entry* next; + peer_sleep_cbk_fn fn; // синхронный обработчик sleep/wake пира + void* arg; // заимствованный контекст обработчика + struct peer_sleep_cbk_entry* next; // следующая подписка uint8_t removed; // снятая подписка не вызывается, память удерживает текущая рассылка }; // Состояние одного узла. Вложенные модули освобождаются через их lifecycle API. struct UTUN_INSTANCE { - struct etcp_tx_history tx_history; - struct reality_owner reality_owner; + struct etcp_tx_history tx_history; // секундное окно ACK всех транспортов ядра + struct reality_owner reality_owner; // relay и replay-кеш REALITY данного ядра uint8_t core_started, utun_started, chat_started; // успешный запуск ядра / UTUN / чата uint8_t destroying; // запрещает повторный запуск ядра/повторное destroy во время очистки @@ -150,7 +149,7 @@ struct UTUN_INSTANCE { struct ROUTE_TABLE* rt; // таблица маршрутов подсетей struct TOPO_GROUPS* topo_groups; // Groups module for topology exchange - sqlite3* topo_sqlite_db; // Shared SQLite DB (nodes/channels/peers) + sqlite3* topo_sqlite_db; // собственная общая SQLite; открывается при create с топологией struct NAT_DETECTION* nat_det; // NAT detection module struct STUN_NAT* stun_nat; // независимая локальная STUN-диагностика UDP struct stun_ports* stun_port_history; // ранее использованные STUN-порты; переживает reload config @@ -164,17 +163,17 @@ struct UTUN_INSTANCE { uint8_t my_ed25519_pubkey[SC_PUBKEY_SIZE]; // свой Ed25519 pubkey (для подписи метрик) uint8_t my_ed25519_privkey[SC_PRIVKEY_SIZE]; // свой Ed25519 privkey (derived from X25519) - uint8_t next_socket_id; // Counter for unique socket IDs (0-255) + uint8_t next_socket_id; // начало поиска свободного ID 0..255; поколение различает повторное использование // Заимствованный цикл событий: создаёт и уничтожает вызывающий. struct UASYNC* ua; // State - int running; // продолжать utun_instance_run; не флаг готовности сервисов + int running; // продолжать цикл приложения; готовность задают отдельные *_started // Connections (очередь всех подключений для instance) struct ll_queue* connections; // indexed by peer_node_id (0=pending), data=conn_queue_entry - struct ll_queue* tcp_connections; // indexed by node_id, data=tcp_conn_entry [TCP-only] + struct ll_queue* tcp_connections; // старый TCP-реестр tcp_conn_entry, fallback instance_find_conn; обычный STCP живёт в links // Callback chain for new ETCP connections struct etcp_inst_cbk_entry* new_conn_cbks; @@ -224,7 +223,7 @@ struct UTUN_INSTANCE { // Media async engine (thread-per-task for crypto/file ops) struct media_async* media_async; - // Socket initialization status: 0=OK, 1=partial (some sockets failed), -1=error (none created) + // Последний результат init/reload endpoint: 0=успех, -1=ошибка хотя бы одного обязательного endpoint. int socket_init_status; // TCP proxy client (optional, NULL if not enabled) @@ -301,47 +300,71 @@ struct UTUN_INSTANCE { uint8_t ncd_control_bound; // 1 = etcp_bind(ETCP_ID_NCD_CONTROL) сделан }; -// Создать ядро по файлу конфигурации; NULL при ошибке. Сервисы ещё не запущены. +/* Создать ядро: ua заимствован, config_file — обязательный путь. Файл может создаваться/нормализоваться updater. + * Уже открывает listeners, SQLite/топологию, router/proxy и мониторинг; core/UTUN/chat ещё не запущены. + * Возвращает instance либо NULL с освобождением частично созданных ресурсов. */ struct UTUN_INSTANCE* utun_instance_create(struct UASYNC* ua, const char* config_file); -// При успехе забирает config; при ошибке config остаётся у вызывающего. +/* Как create, но без updater и файлового parse: config должен содержать корректные ключи. + * Успех передаёт владение config ядру; при NULL config остаётся caller (node_id мог быть исправлен). + * log_file и debug меняют общий logger процесса даже при последующем отказе создания. */ struct UTUN_INSTANCE* utun_instance_create_from_config(struct UASYNC* ua, struct utun_config* config); -// Как create, но конфигурация передана строкой; строку не сохраняет. +/* Как create из ASCIIZ config_text; строка заимствована лишь на вызов. Использует mkstemp в /tmp, + * updater и файловый parse, затем unlink. Нужна доступная ФС; ошибки дают NULL. */ struct UTUN_INSTANCE* utun_instance_create_from_str(struct UASYNC* ua, const char* config_text); -// Остановить сервисы и освободить instance. Переданный uasync остаётся у вызывающего. +/* Остановить сервисы/транспорт и освободить instance/config; NULL допустим. UASYNC остаётся caller. + * Выполняет drain отложенных callbacks и join media workers; вызывать вне callbacks и без параллельных обращений. + * Заимствованные SQLite statements/backup должны быть закрыты caller; BUSY логируется и переводится в close_v2. */ void utun_instance_destroy(struct UTUN_INSTANCE* instance); -/* Запустить общие сетевые службы и постоянные транспорты [client], без членства в группах. Повтор допустим; 0/-1. */ +/* Запустить репликацию, NCD/keepalive, [client], control и время; без вступления в группы. Успех: running=core_started=1. + * Повтор успешного start — 0. Ошибка — -1; уже подготовленные ресурсы остаются для retry/destroy. + * Отказ NTP/STUN не фатален. NULL/разрушаемый instance отклоняется. */ int utun_core_start(struct UTUN_INSTANCE* instance); -/* Запустить UTUN-группу, TUN/NAT по конфигу и групповые запросы к настроенным пирам. Нужен core_start; 0/-1. */ +/* Запустить UTUN-группу, TUN/NAT по конфигу и отдельные запросы к [client]. Нужны core_start и топология. + * 0 — успех/уже запущен; -1 — откат ресурсов UTUN, ядро и чат сохраняются. */ int utun_service_start(struct UTUN_INSTANCE* instance); /* Остановить UTUN, сохранив ядро и чат. Повтор допустим. */ void utun_service_stop(struct UTUN_INSTANCE* instance); -/* Запустить ядро, UTUN и включённый в конфиге чат. 0/-1. */ +/* Последовательно core_start → UTUN (при наличии топологии) → чат (chatserver_enabled). + * 0/-1; ошибка позднего сервиса сохраняет ранее запущенные сервисы. */ int utun_instance_init(struct UTUN_INSTANCE *instance); -/* Перечитать конфигурацию, при необходимости пересоздать экземпляр; использовать возвращённый указатель. */ +/* Перечитать config_file через updater; ua должен совпадать с instance->ua. Всегда использовать результат. + * Ошибка подготовки сохраняет прежний указатель. Смена негорячих global/подсетей либо automatic servers + * пересоздаёт ядро с прежними core/UTUN/chat/running; ошибка после destroy возвращает NULL. + * Ручные endpoints обновляются по имени и всем параметрам, неизменённые сохраняются (включая port=0). + * Частичный reload не транзакционный: отказ endpoint логируется, новый config сохраняется для следующего retry. */ struct UTUN_INSTANCE *utun_instance_reload(struct UTUN_INSTANCE *instance, struct UASYNC *ua, const char *config_file); -/* Выполнять цикл событий до stop(). */ -void utun_instance_run(struct UTUN_INSTANCE *instance); /* Снять running и разбудить цикл; ресурсы освобождает destroy(), а не stop(). */ void utun_instance_stop(struct UTUN_INSTANCE *instance); -/* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */ +/* Общий переключатель TUN (по умолчанию 0): влияет на последующий UTUN start и proxy test mode при create. */ void utun_instance_set_tun_init_enabled(int enabled); +/* Общий переключатель топологии (по умолчанию 1): влияет на последующие create, включая открытие SQLite. */ void utun_instance_set_topo_group_enabled(int enabled); /* Сохраняет желаемый режим frontend; stream owners удерживают ACTIVE до последнего close. * Применяет эффективную активность и мобильный standby до уведомления подписчиков; выполнять в uasync-потоке. - * Повторная команда согласует фактический режим без повторного BGP-события. 0 — успех, <0 — ошибка. */ + * Повторная команда согласует фактический режим без повторного BGP-события. SERVER — успешный no-op. + * 0 — успех, -1 — NULL/ошибка standby с откатом режима. phone_state не меняется. */ int utun_set_client_activity(struct UTUN_INSTANCE* instance, int active); +/* Подписки уникальны по fn+arg, новые идут в голову; arg заимствован. NULL instance/fn — no-op, + * OOM логируется без добавления. Remove из callback безопасен; память удерживается до конца внешней рассылки. + * Новая подписка не получает уже начатое событие, но участвует во вложенном. Destroy внутри callback запрещён. */ void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg); +/* Удалить подписку fn+arg; отсутствие — no-op. Вложенная новая активность отменяет остаток старого события. */ void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg); +/* Аналогичная подписка на peer_node_id/sleeping; nesting исполняется синхронно до продолжения внешней рассылки. */ void utun_add_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_fn fn, void* arg); +/* Удалить peer-sleep подписку fn+arg; отсутствие/NULL — no-op. */ void utun_remove_peer_sleep_cbk(struct UTUN_INSTANCE* instance, peer_sleep_cbk_fn fn, void* arg); +/* Синхронно передать подписчикам node_id и sleeping (1 сон, 0 пробуждение); NULL instance — no-op. */ void utun_fire_peer_sleep_cbk(struct UTUN_INSTANCE* instance, uint64_t peer_node_id, int sleeping); -// Diagnostic function for memory leak analysis +/* Вывести sys/info счётчики ресурсов и простые sanity-проверки списков с меткой phase. + * Живые ресурсы сами по себе не означают утечку; функция ничего не освобождает. */ void utun_instance_diagnose_leaks(struct UTUN_INSTANCE* instance, const char* phase); /* Найти транспорт по node_id в реестрах ETCP/TCP. Указатель заимствован. * NULL — не найден; ненулевой результат не гарантирует UP или готовность группы. */ static inline struct ETCP_CONN* instance_find_conn(struct UTUN_INSTANCE* inst, uint64_t node_id) { + if (!inst) return NULL; if (inst->connections) { struct ll_entry* e = queue_find_data_by_index(inst->connections, (const uint8_t*)&node_id); if (e) return ((struct conn_queue_entry*)e->data)->conn; From 59f900810ed42969f63217af0f71684a26e4f9a0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 01:54:43 +0100 Subject: [PATCH 30/45] Check headless clients, timer rollback and external subscription cleanup --- tests/Makefile.am | 2 +- tests/test_instance_contract.c | 36 +++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/Makefile.am b/tests/Makefile.am index 0b948f57..6e14280c 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -290,7 +290,7 @@ check_PROGRAMS += test_config_parser check_PROGRAMS += test_instance_contract test_instance_contract_SOURCES = test_instance_contract.c test_instance_contract_LDADD = $(LIBUTUN) $(COMMON_LIBS) -test_instance_contract_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=etcp_create_server_socket +test_instance_contract_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=etcp_create_server_socket -Wl,--wrap=uasync_set_timeout test_config_parser_SOURCES = test_config_parser.c test_config_parser_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_config_parser_LDFLAGS = -Wl,--wrap=u_calloc_impl -Wl,--wrap=u_realloc_impl diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index a457b906..b857de15 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -15,6 +15,14 @@ #include static int fail_subscription, fail_queue, fail_endpoint; +static const char* fail_timer; +extern void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*); +void* __wrap_uasync_set_timeout(struct UASYNC* ua, int timeout, void* arg, timeout_callback_t callback, const char* name) { + if (fail_timer && !strcmp(fail_timer, name)) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "injected timer failure name=%s", name); fail_timer = NULL; return NULL; + } + return __real_uasync_set_timeout(ua, timeout, arg, callback, name); +} extern void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); void* __wrap_u_calloc_impl(uint32_t count, uint32_t size, const char* where) { if (fail_subscription && strstr(where, "utun_instance.c")) { fail_subscription = 0; return NULL; } @@ -88,6 +96,12 @@ static struct ETCP_SOCKET* endpoint(struct UTUN_INSTANCE* inst, const char* name static unsigned endpoint_count(struct UTUN_INSTANCE* inst) { unsigned count = 0; for (struct ETCP_SOCKET* s = inst->etcp_sockets; s; s = s->next) count++; return count; } +static void connection_created(struct ETCP_CONN* conn, void* arg) { (void)conn; (void)arg; } +static void connection_status(struct ETCP_CONN* conn, int event, void* arg) { (void)conn; (void)event; (void)arg; } +static void link_status(struct ETCP_CONN* conn, struct ETCP_LINK* link, int state, int status, void* arg) { + (void)conn; (void)link; (void)state; (void)status; (void)arg; +} +static void socket_status(struct ETCP_SOCKET* sock, int event, void* arg) { (void)sock; (void)event; (void)arg; } static void test_creation(struct UASYNC* ua) { size_t baseline = u_get_allocated_count(); @@ -143,6 +157,8 @@ static void test_reload(struct UASYNC* ua) { assert(!strcmp(inst->name, "new name") && !inst->running && endpoint_count(inst) == 2 && stcp_server_list_count(inst) == 1); assert(sqlite3_finalize(statement) == SQLITE_OK); struct UASYNC wrong_owner = {0}; assert(utun_instance_reload(inst, &wrong_owner, path) == inst); + etcp_add_new_conn_cbk(inst, connection_created, NULL); etcp_add_conn_status_cbk(inst, connection_status, NULL); + etcp_add_link_status_cbk(inst, link_status, NULL); etcp_add_socket_cbk(inst, socket_status, NULL, 3); utun_instance_destroy(inst); uasync_drain_immediate(ua); unlink(path); DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS reload: ephemeral stability, malformed config, queue/create faults, TCP change/delete and restart state"); } @@ -155,17 +171,35 @@ static void test_headless(struct UASYNC* ua) { struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, text); assert(inst && !utun_core_start(inst)); assert(!chat_service_start(inst) && !inst->utun_started); assert(chat_headless_control_get_port(inst) && call_headless_get_port(inst) && radio_headless_get_port(inst)); + uint16_t ports[] = {chat_headless_control_get_port(inst), call_headless_get_port(inst), radio_headless_get_port(inst)}; + socket_t clients[3]; + for (unsigned i = 0; i < 3; i++) { + clients[i] = socket(AF_INET, SOCK_STREAM, 0); assert(clients[i] != SOCKET_INVALID); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons(ports[i])}; + assert(inet_pton(AF_INET, "127.0.0.1", &address.sin_addr) == 1); + assert(!connect(clients[i], (struct sockaddr*)&address, sizeof(address))); + } + for (unsigned i = 0; i < 5; i++) uasync_poll(ua, 1); sqlite3* db = inst->topo_sqlite_db; chat_service_stop(inst); assert(inst->core_started && inst->topo_sqlite_db == db && !inst->chat_started); assert(!chat_headless_control_get_port(inst) && !call_headless_get_port(inst) && !radio_headless_get_port(inst)); + assert(!inst->call_audio && !inst->radio_headless && !inst->headless); + for (unsigned i = 0; i < 3; i++) socket_close_wrapper(clients[i]); + const char* timer_names[] = {"ca_cleanup", "rh_cleanup", "rh_mix"}; + for (unsigned i = 0; i < 3; i++) { + fail_timer = timer_names[i]; assert(!chat_service_start(inst) && !fail_timer); + if (!i) assert(!inst->call_audio && !call_headless_get_port(inst)); + else assert(!inst->radio_headless && !radio_headless_get_port(inst)); + chat_service_stop(inst); + } strcpy(inst->config->global.headless_control_bind, "127.0.0.1:garbage"); strcpy(inst->config->global.call_audio_bind, "127.0.0.1:65536"); strcpy(inst->config->global.radio_audio_bind, "127.0.0.1"); assert(!chat_service_start(inst)); assert(!chat_headless_control_get_port(inst) && !call_headless_get_port(inst) && !radio_headless_get_port(inst)); utun_instance_destroy(inst); - DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS chat-only lifecycle: three ephemeral headless ports, retained SQLite and malformed bind rejection"); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS chat-only lifecycle: three headless ports/clients, timer faults, retained SQLite and malformed binds"); } int main(void) { From 68ead8c7dc002b9a6af4043ebc5b818a452c887b Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 03:50:35 +0100 Subject: [PATCH 31/45] Expose remaining async resource owners in instance regression test --- tests/test_instance_contract.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index b857de15..2597cac0 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -209,6 +209,8 @@ int main(void) { size_t baseline = u_get_allocated_count(); struct UASYNC* ua = uasync_create(); assert(ua); test_creation(ua); test_reload(ua); test_headless(ua); + debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); + uasync_print_resources(ua, "instance contract before owner destroy"); assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); uasync_destroy(ua, 0); // кэш таймеров принадлежит UASYNC, освобождается вместе с ним if (u_get_allocated_count() != baseline) u_report_unfreed_blocks(); From 817e3744ee96cb387a8b0bb8527ebf8cfe875844 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 03:51:09 +0100 Subject: [PATCH 32/45] Reclaim cancelled heap timers before checking async resource counters --- tests/test_instance_contract.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index 2597cac0..8c4a5b89 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -209,6 +209,7 @@ int main(void) { size_t baseline = u_get_allocated_count(); struct UASYNC* ua = uasync_create(); assert(ua); test_creation(ua); test_reload(ua); test_headless(ua); + uasync_poll(ua, 0); // удалённые heap-таймеры освобождаются при следующем poll debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); uasync_print_resources(ua, "instance contract before owner destroy"); assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); From ceaf71caf8e0eedf7da76d17333787e3d4b0700e Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:41:39 +0100 Subject: [PATCH 33/45] Align headless PCM before passing frames to Opus --- src/call/call_headless.c | 3 ++- src/radio/radio_headless.c | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/call/call_headless.c b/src/call/call_headless.c index 1dbb4994..a2c76673 100644 --- a/src/call/call_headless.c +++ b/src/call/call_headless.c @@ -198,8 +198,9 @@ static void ca_parse_frames(struct call_audio_client* cli) { CH_ID, flen, CA_PCM_FRAME_BYTES); } else { opus_codec_encoder_t* enc = ca_encoder(ca, call_id); + int16_t pcm[CA_FRAME_SAMPLES]; memcpy(pcm, data, sizeof(pcm)); // wire header 11 байт не выровнен для int16_t uint8_t opus[256]; - int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; + int olen = enc ? opus_codec_encode(enc, pcm, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; if (olen <= 0) { ca->c_enc_dropped++; DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: encode failed (olen=%d) call_id=%016llx", diff --git a/src/radio/radio_headless.c b/src/radio/radio_headless.c index 9231b35d..a978467f 100644 --- a/src/radio/radio_headless.c +++ b/src/radio/radio_headless.c @@ -336,8 +336,9 @@ static void rh_parse_frames(struct radio_headless_client* cli) { RH_ID, flen, RH_PCM_FRAME_BYTES); } else { opus_codec_encoder_t* enc = rh_encoder(rh, group_id); + int16_t pcm[RH_FRAME_SAMPLES]; memcpy(pcm, data, sizeof(pcm)); // wire header 11 байт не выровнен для int16_t uint8_t opus[256]; - int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, RH_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; + int olen = enc ? opus_codec_encode(enc, pcm, RH_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; if (olen <= 0) { rh->c_enc_dropped++; DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: encode failed (olen=%d) group=0x%016llx", From 9201d8c4f775a68a33c1bbbc783dd05c6c8e9688 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:42:03 +0100 Subject: [PATCH 34/45] Use aligned PCM samples in headless radio integration fixture --- tests/test_radio_headless.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_radio_headless.c b/tests/test_radio_headless.c index 364f16f0..f3708f5b 100644 --- a/tests/test_radio_headless.c +++ b/tests/test_radio_headless.c @@ -281,7 +281,7 @@ static int hr_has_nonzero_pcm(struct hr_conn* c) { uint16_t flen = (uint16_t)(((uint8_t)buf[i + 9] << 8) | (uint8_t)buf[i + 10]); if (flen != RADIO_AUDIO_PCM_FRAME_BYTES) continue; if (i + RADIO_AUDIO_HDR_SIZE + flen > len) break; - const int16_t* pcm = (const int16_t*)(buf + i + RADIO_AUDIO_HDR_SIZE); + int16_t pcm[960]; memcpy(pcm, buf + i + RADIO_AUDIO_HDR_SIZE, sizeof(pcm)); for (int k = 0; k < 960; k++) if (pcm[k] != 0) return 1; } return 0; @@ -361,7 +361,8 @@ static void hr_tick(void* arg) { frm[0] = RADIO_AUDIO_FRAME; memcpy(frm + 1, &gid, 8); frm[9] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES >> 8); frm[10] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES & 0xFF); - for (int k = 0; k < 960; k++) ((int16_t*)(frm + RADIO_AUDIO_HDR_SIZE))[k] = 1000; + int16_t pcm[960]; for (int k = 0; k < 960; k++) pcm[k] = 1000; + memcpy(frm + RADIO_AUDIO_HDR_SIZE, pcm, sizeof(pcm)); hr_send(&t->aud[IDX_A], frm, sizeof(frm)); ptt[0] = RADIO_AUDIO_PTT_STOP; From 06ad255b9501d217588cea644a23aad55fc2e150 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:43:55 +0100 Subject: [PATCH 35/45] Align instance guide and core lifecycle documentation with audited code --- doc/service_lifecycle.md | 36 +++++- src/utun_instance_doc.md | 257 +++++++++++++++++++++++++-------------- 2 files changed, 198 insertions(+), 95 deletions(-) diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index d932bc70..7c46ebbc 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -29,9 +29,17 @@ utun_instance_destroy(inst); Desktop подтверждает start только после готовности всех сервисов и uasync poll; ошибка запрошенного TUN откатывает запуск и возвращается в GUI. Android сохраняет тот же выбор при рестарте. Повторный start работающего сервиса и повторный stop допустимы. Ошибка старта -сервиса откатывает созданные им ресурсы. `utun_instance_stop()` останавливает цикл +UTUN/чата откатывает созданные им ресурсы; ошибка core start оставляет подготовленные +ресурсы для retry/destroy. `utun_instance_init()` сохраняет ранее запущенные сервисы +при отказе последующего. `utun_instance_stop()` останавливает цикл приложения; освобождение ядра выполняет `utun_instance_destroy()`. +Create уже подготавливает listeners, топологию/SQLite, router/proxy и мониторинг; +core/UTUN/chat запускаются отдельно. `create_from_str` использует временный файл +в `/tmp`, updater и parse. `create_from_config` передаёт владение config только +при успехе; при отказе config остаётся caller, но node_id мог быть исправлен. +Logger общий для процесса, его изменения не откатываются при отказе создания. + Для TUN в desktop `vibechat.cfg` задаются те же параметры, что и у CLI: ```ini @@ -83,7 +91,14 @@ UTUN stop закрывает свои групповые запросы, но с окончательный CLOSED запускает повтор через секунду. Удаление записи отменяет её повтор и освобождает только её handles. Ошибка подготовки нового списка сохраняет прежний список. Изменение автосокетов переприменяет сохранённые endpoints также для bootstrap без подписанного NODEINFO. -Полный reload сохраняет набор запущенных сервисов; частичный обновляет `[client]` также без UTUN. +Полный reload сохраняет набор запущенных сервисов и running; частичный обновляет `[client]` также без UTUN. +Restart нужен при смене негорячих global-настроек (в том числе db_path, control, +TUN/NAT/proxy), подсетей либо server-конфига в automatic режиме. В ручном режиме +сравниваются все параметры endpoint и IPv6 scope; неизменённый port=0 сохраняет +порт ОС. TCP endpoint закрывается вместе с listener и своей TCP-моделью. +Ошибка подготовки сохраняет прежний instance; ошибка создания после полного destroy +возвращает NULL. Частичный reload не атомарен: отказ нового endpoint логируется, +новый config сохраняется для повторной попытки. Caller всегда использует результат reload. Proxy настраивается при создании ядра. Instance владеет общими UDP/ICMP-контекстами; TCP client/server владеют только своими bindings, listeners и TCP-потоками. @@ -98,6 +113,16 @@ UTUN/CHAT-групп и транспорта. Waiters/таймеры снима SQLite открывается ядром один раз: `db_path/chats.db`, либо `:memory:`, если путь не задан. Остановка чата сохраняет каналы и сообщения в этой БД. В варианте `:memory:` данные сохраняются только до уничтожения ядра. +Caller завершает заимствованные SQLite statements/backups до destroy. Ошибка close +логируется; close_v2 при BUSY откладывает освобождение БД до завершения этих объектов. +UASYNC живёт до конца destroy. Отменённые heap-таймеры могут оставаться в нём +до следующего poll; это не разрешает callback обращаться к освобождённому instance. + +Headless chat/call/radio bind задаёт IPv4 literal:port, decimal 0..65535; +port=0 выбирает ОС. Ошибка bind/listener не останавливает чат и не открывает +порт по умолчанию. Stop снимает клиентов с UASYNC, отменяет cleanup/mix-таймеры, +освобождает headless-контексты и кодеки. PCM после 11-байтового заголовка +копируется в выровненный буфер перед Opus. Работающий общий транспорт сохраняется, пока он нужен хотя бы одному из пиров. Закрытие запроса одной группы не разрывает соединение, нужное другой группе. При остановке группы отменяются @@ -436,3 +461,10 @@ standby даже при начальном `client_activity=STANDBY`. Keepalive handshake. Пробуждение по таймеру и внешней сети сначала сообщает ACTIVE-окно пирам, затем запускает ожидающие задачи. При выходе сначала восстанавливаются линки; deinit отменяет ожидающие задачи и очищает подписки старого ядра. + +Activity/peer-sleep подписки уникальны по fn+arg и вызываются синхронно в обратном +порядке добавления. Remove себя/соседа безопасен: память записи освобождается +после внешней рассылки. Новые подписки могут получить вложенное событие; +более новая эффективная activity прерывает остаток прежней рассылки. +Destroy/reload из этих callbacks запрещён. `test_instance_contract` проверяет +вложенные подписки, отказы ресурсов, manual/full reload, TCP listener и headless stop/start. diff --git a/src/utun_instance_doc.md b/src/utun_instance_doc.md index a8704336..666cd23d 100644 --- a/src/utun_instance_doc.md +++ b/src/utun_instance_doc.md @@ -1,112 +1,183 @@ -# utun_instance — Корневой инстанс (центральный оркестратор) +# utun_instance — ядро одного узла и независимые сервисы -## 1. Назначение +Полностью сверены `utun_instance.c/.h` и этот документ, 26.10.2026. +Связанный документ [service_lifecycle.md](../doc/service_lifecycle.md) описывает также GUI, +Android и media workers; аудит instance не означает завершения всех его разделов. -`UTUN_INSTANCE` — **центральная структура всего процесса uTun**. Владеет всеми подсистемами: TUN, таблица маршрутов, BGP-группы, ETCP-сокеты/соединения, control-сервер, message transport, firewall, NAT, TCP-прокси, memory pools, NTP, синхронизация БД, менеджер соединений. +## Использование и поток -Это **единственная точка входа для инициализации и завершения** всех компонентов. Порядок инициализации и destroy строго определён — от низкоуровневых подсистем к высокоуровневым и обратно. +`UTUN_INSTANCE` принадлежит одному узлу. Несколько экземпляров могут разделять +переданный `UASYNC`; он принадлежит вызывающему и должен жить до конца destroy. +Все операции выполняются в его потоке. Stop сервиса, reload и destroy выполняются +вне callbacks останавливаемых модулей. Межпоточные команды передаются в uasync; +прямые конкурентные обращения к instance недопустимы. -## 2. Как пользоваться - -### Создание ```c struct UASYNC* ua = uasync_create(); -struct UTUN_INSTANCE* inst = utun_instance_create(ua, "utun.conf"); // из файла -// или -struct UTUN_INSTANCE* inst = utun_instance_create_from_config(ua, config); // из готовой структуры -// или -struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config_text); // из строки (тесты) -``` - -### Инициализация (оркестрация) -```c -utun_instance_init(inst); // запускает всю цепочку инициализации -``` -`utun_instance_init()` **не вызывает** `utun_instance_create()` — create только аллоцирует память и загружает конфиг. `init` запускает все подсистемы в строгом порядке: -1. `db_sync_init` — распределённая БД с репликацией -2. `routing_set_tun` — привязка TUN к таблице маршрутов -3. `nat_transport_init` — NAT (если включён в конфиге) -4. `init_connections` — ETCP-сокеты, клиентские подключения, handshake -5. `control_server_init` — сервер мониторинга (etcpmon) -6. `ntp_time_init` + `ntp_node_time_init` — синхронизация времени - -### Основной цикл -```c -inst->running = 1; -while (inst->running) { - uasync_poll(inst->ua, 100); +if (!ua) return -1; +struct UTUN_INSTANCE* inst = utun_instance_create(ua, "utun.cfg"); +if (!inst || utun_core_start(inst) < 0 || chat_service_start(inst) < 0) { + utun_instance_destroy(inst); + uasync_destroy(ua, 0); + return -1; } +while (inst->running) uasync_poll(ua, 100); +utun_instance_destroy(inst); +uasync_destroy(ua, 0); ``` -### Остановка и destroy +Цикл запускает приложение; функции `utun_instance_run` нет. +`utun_instance_stop` только снимает `running` и вызывает wakeup. Это не stop сервисов. +При общем UASYNC решение о продолжении poll принимает его владелец. + +## Конструкторы + +| API | Действие и владение | +|---|---| +| `create(ua, path)` | Проверяет аргументы, запускает config updater, разбирает файл и создаёт ядро. Updater может создать/нормализовать файл, ключи и node_id | +| `create_from_config(ua, config)` | Использует готовую модель без updater. При успехе забирает config; при ошибке caller продолжает владеть им | +| `create_from_str(ua, text)` | Копирует ASCIIZ текст в mkstemp `/tmp/utun_cfg_XXXXXX`, вызывает файловый create и удаляет temp. Требует доступной ФС; текст не сохраняется | + +Все возвращают NULL при ошибке и освобождают частично созданные ресурсы. +При `create_from_config` ключи должны быть заданы корректно; производный node_id +проверяется и может исправляться в модели даже при последующей ошибке. +`log_file` и уровни debug меняют logger всего процесса, включая неудачный create. +Путь лога копируется в постоянный буфер, чтобы пережить освобождение config. + +Create уже выводит идентичность X25519/Ed25519, создаёт индекс сетей и соединений, +пулы, routing, обязательные UDP/TCP listeners, топологию/SQLite, мониторинг и +automatic sockets, firewall, router/multicast, NAT detection и настроенный proxy. +Это I/O с возможными ошибками bind, каталогов, БД и ресурсов; создание ядра +не ограничивается выделением структуры. + +При включённой топологии SQLite открывается один раз: `db_path/chats.db`, без пути — +`:memory:`. Каталог подготавливается рекурсивно. Core/UTUN/chat ещё не запущены: +их флаги и `running` равны нулю. Неуспешный обязательный listen или proxy отклоняет +create. Недоступность ICMP raw socket по правам/поддержке отключает только ICMP exit с WARN. + +## Запуск и остановка + +| API | Ресурсы и результат | +|---|---| +| `utun_core_start` | db_sync, NCD/keepalive, постоянные транспорты `[client]`, control, время, broadcast/radio и STUN. Успех задаёт `core_started=running=1` | +| `utun_service_start` | Требует core и топологию. Создаёт UTUN-группу, DATA binding, опциональные TUN/маршруты/NAT и отдельные групповые запросы к `[client]` | +| `chat_service_start` | Объявлен в chat_core.h. Требует core и SQLite. Поднимает CHAT-синхронизацию, stream/media/DM/member_files/call/radio-call, headless и LAN | +| `utun_instance_init` | Последовательно core, затем UTUN при наличии топологии, затем чат при `chatserver_enabled` | + +Start возвращает 0 при успехе или повторном запуске, -1 при ошибке. Готовность +локальных ресурсов не гарантирует UP транспорта или READY группы. +Ошибка старта UTUN/чата вызывает stop этого сервиса. Core start оставляет уже +подготовленные ресурсы для retry/destroy; NTP и STUN нефатальны. +`init` не откатывает ранее запущенные сервисы при отказе последующего. + +UTUN stop снимает DATA binding, TUN/NAT/маршруты, свои групповые запросы и UTUN-группу. +Постоянные `[client]`-транспорты ядра и чат сохраняются. +Chat stop прекращает LAN/headless/stream и фоновые задачи, дожидается workers, +затем закрывает медиа/DM/синхронизацию/CHAT-группы и chat core. SQLite остаётся; +следующий start загружает сохранённые каналы. Stop обоих сервисов идемпотентен. + +Три headless bind-поля принимают IPv4 literal и десятичный порт 0..65535. +Пустая строка отключает listener; 0 выбирает порт ОС, доступный через getter. +Плохой bind или ошибка listener логируется и отключает только его; чат остаётся запущенным. +Call/radio stop отменяет таймеры, снимает клиентов с UASYNC, освобождает кодеки и контекст. +После 11-байтового wire-заголовка PCM копируется в выровненный int16_t буфер перед Opus. + +Глобальный переключатель `set_tun_init_enabled` по умолчанию выключен; влияет +на последующий UTUN start и proxy test mode при create. Дополнительно нужен +`config.global.tun_enabled`. `set_topo_group_enabled` по умолчанию включён; +влияет на последующие create, включая общую БД. Это общие настройки процесса, +а не операции включения/выключения ресурсов существующего instance. + +## Reload + +Всегда сохранять возвращённый указатель: + ```c -utun_instance_stop(inst); // running=0, wakeup -utun_instance_destroy(inst); // полный cleanup в обратном порядке +inst = utun_instance_reload(inst, ua, "utun.cfg"); +if (!inst) { /* прежнее ядро уже уничтожено; обработать ошибку */ } ``` -`destroy` гарантирует порядок: NTP→control→ETCP sockets→db_sync→connections→pings→TUN→tcp_proxy→routing→NAT→etcp_router→conn_mgr→BGP→fw→stcp→networks→config→pools. **uasync не уничтожается** — вызывающий код должен сам вызвать `uasync_destroy()`. -### Reload (SIGHUP) -```c -inst = utun_instance_reload(inst, ua, "utun.conf"); -``` -Сравнивает старый и новый конфиг: -- Если изменились ключи/node_id/tun_ifname → **полный перезапуск** (destroy + create + init) -- Иначе — **селективное обновление**: только изменённые/добавленные/удалённые сокеты, клиенты, линки. Неизменённые не трогаются (без close, без сброса таймеров). +Нужны существующий instance, его же UASYNC и непустой путь. Сначала переоткрывается +лог, updater проверяет файл, затем готовятся новая модель и firewall. +Ошибка подготовки возвращает прежний instance; updater и logger могли уже изменить +файл/глобальные настройки, поэтому общей транзакции с ними нет. -## 3. API +Полный restart выбирается при изменении негорячих global-настроек, списков +my_subnet/route_subnet, а также server-конфига в automatic режиме. +Сюда входят идентичность, имя, db_path, TUN/NAT/proxy, control, keepalive и режим сокетов. +Горячими global-полями считаются firewall, уровни debug и STUN. +Списки сравниваются по содержимому; различия порядка и padding модели могут +консервативно выбрать restart. -### Структура `UTUN_INSTANCE` — ключевые поля +Полный путь уничтожает старое ядро, создаёт новое из уже разобранной модели +и восстанавливает прежние `core_started/utun_started/chat_started/running`. +Изменение config-флага само по себе не включает ранее остановленный сервис. +Ошибки создания/восстановления после destroy возвращают NULL. In-memory SQLite +теряет данные, дисковая БД сохраняется. -| Поле | Тип | Назначение | -|------|-----|------------| -| `name` | `char[16]` | Имя инстанса из конфига | -| `config` | `struct utun_config*` | Полный разобранный конфиг | -| `node_id` | `uint64_t` | Идентификатор узла (из конфига или derived из privkey) | -| `my_keys` | `struct SC_MYKEYS` | Пара ключей X25519 (pub/priv) | -| `my_ed25519_pubkey` / `_privkey` | `uint8_t[32]` | Ed25519 ключи (для подписи) | -| `ua` | `struct UASYNC*` | Главный event loop (один на поток) | -| `running` | `int` | Флаг работы главного цикла | -| `tun` | `struct tun_if*` | TUN-интерфейс | -| `route_subnets` | `struct CFG_ROUTE_ENTRY*` | Маршруты для добавления/удаления системных роутов | -| `rt` | `struct ROUTE_TABLE*` | Таблица маршрутов | -| `topo_groups` | `struct TOPO_GROUPS*` | BGP-подобный обмен маршрутами | -| `connections` | `struct ll_queue*` | Очередь всех ETCP-соединений (индекс по peer_node_id) | -| `etcp_sockets` | `struct ETCP_SOCKET*` | Связный список UDP-сокетов | -| `stcp_server` | `struct stcp_server*` | STCP TCP-сервер | -| `data_pool` | `struct memory_pool*` | Пул для данных пакетов (payload) | -| `pkt_pool` | `struct memory_pool*` | Пул для `struct ETCP_DGRAM` | -| `ack_pool` | `struct memory_pool*` | Пул для `struct ACK_PACKET` | -| `control_srv` | `struct control_server*` | Сервер мониторинга (etcpmon backend) | -| `fw` | `struct firewall_ctx` | Контекст файрвола | -| `nat` / `nat_tr` | `struct eim_nat_ctx` / `nat_transport_ctx` | EIM NAT + транспорт | -| `tcp_proxy_client` | `struct tcp_proxy_client*` | TCP-прокси клиент (опционально) | -| `tcp_proxy_server` | `struct tcp_proxy_server` | TCP-прокси сервер (exit node) | -| `router_bindings` / `router_conns` | | Привязки и соединения ETCP-роутера | -| `conn_mgr` | `struct CONN_MGR*` | Менеджер соединений (может быть NULL) | -| `db_sync` | `struct DB_SYNC*` | Распределённая синхронизация БД (может быть NULL) | -| `networks` | `struct ll_queue*` | Очередь сетей (NETWORK_ENTRY, индекс по 56-bit id) | -| `ntp` / `ntp_node` | `struct NTP_TIME` / `NTP_NODE_TIME` | Синхронизация времени | -| `next_socket_id` | `uint8_t` | Счётчик уникальных ID сокетов (0–255) | -| `socket_init_status` | `int` | Статус инициализации сокетов: 0=OK, 1=частично, -1=ошибка | -| `routed_packets` / `dropped_packets` | `uint64_t` | Счётчики маршрутизированных/отброшенных пакетов | +Частичный путь заранее готовит отдельный индекс сетей. В ручном режиме endpoints +сопоставляются по имени `[server:name]` и всем параметрам: адрес/порт/IPv6 scope, +UDP/TCP, интерфейс, mark/FIB, MTU, type, REALITY/SOCKS, IPv6 mode и only_local. +Неизменённый endpoint сохраняет socket ID, generation и выбранный ОС порт при port=0. +Удаление TCP закрывает listener и TCP-модель, UDP использует собственный teardown. +Probe и endpoints других владельцев не удаляются только из-за отсутствия в server-конфиге. -### Основные функции +Новые endpoints создаёт общая UDP/TCP factory. При изменении servers/clients +перестраиваются постоянные `[client]`; неизменённый список сохраняет handles/таймеры. +Копии клиентских параметров не ссылаются на прежний config. +Далее заменяются config, networks и firewall, перепубликуются изменившиеся адреса +и перезапускается STUN при работающем core. -| Функция | Описание | -|---------|----------| -| `utun_instance_create(ua, config_file)` | Загрузка конфига, аллокация инстанса, вызов `instance_init_common()`. Возвращает готовый к `init` инстанс или NULL | -| `utun_instance_create_from_config(ua, config)` | Создание из уже разобранного конфига (владение передаётся инстансу) | -| `utun_instance_create_from_str(ua, config_text)` | Создание из строки конфига (временный файл → parse → delete). Для тестов | -| `utun_instance_init(instance)` | **Основной оркестратор инициализации**. Запускает все подсистемы в правильном порядке, устанавливает `running=1` | -| `utun_instance_destroy(instance)` | **Полный cleanup** всех ресурсов в обратном порядке зависимостей | -| `utun_instance_stop(instance)` | Установка `running=0` + `uasync_wakeup()` для выхода из mainloop | -| `utun_instance_reload(instance, ua, config_file)` | Перезагрузка конфига по SIGHUP: полная при изменении ключей/TUN, иначе селективная | -| `utun_instance_set_tun_init_enabled(enabled)` | Глобальный флаг: включать инициализацию TUN или нет (для тестов без TUN) | -| `utun_instance_diagnose_leaks(instance, phase)` | Диагностика: подсчёт ETCP-сокетов/соединений/линков, проверка утечек пулов и TUN | +Это не атомарная замена sockets: отказ нового endpoint сохраняет новый config +и отсутствие endpoint для следующей попытки reload. Ошибка подготовки клиентов +сохраняет их прежние самостоятельные handles. Ошибки выводятся в лог; +`socket_init_status=-1` означает отказ хотя бы одного endpoint, 0 — успех. -### Внутренние (static) +## Подписки активности и сна пира -| Функция | Описание | -|---------|----------| -| `instance_init_common(instance, ua, config)` | Общая инициализация для всех create-функций: ключи, node_id, networks, memory pools, routing, TUN, сокеты, BGP, conn_mgr, firewall, etcp_router, routing_bind, tcp_proxy_server/client | -| `local_sockaddr_equal(a, b)` | Сравнение двух sockaddr_storage (IPv4/IPv6) — используется в reload | +Activity и peer-sleep подписки принадлежат instance; fn/arg заимствованы. +Пара fn+arg уникальна; повторный add ничего не меняет. Новые записи идут в голову, +события доставляются синхронно в обратном порядке добавления. +NULL instance/fn и remove отсутствующей записи — no-op; OOM add логируется. + +В callback разрешено удалить себя/соседа и добавить подписку. +Удалённые записи немедленно пропускаются, память удерживается до конца внешней +рассылки обоих видов событий. Новая подписка не участвует в текущем обходе, +но может получить вложенное событие. Вложенный peer-sleep исполняется до продолжения +внешнего; новая эффективная activity прекращает остаток старой activity-рассылки. +Reload/destroy внутри таких callbacks запрещён. + +`utun_set_client_activity` нормализует requested mode, учитывает stream hold, +применяет Android standby при соответствующей сборке и затем рассылает изменение. +Повтор согласует режим без повторного события; ошибка standby откатывает состояние. +SERVER возвращает 0 без изменения; phone_state этим API не меняется. + +## Уничтожение и диагностика + +Destroy снимает running/core_started и запрещает повторный вход, останавливает +STUN/NAT detection/padding, NTP/control/proxy, чат/UTUN и core-client handles, +затем мониторинг/automatic sockets и топологию/broadcast/radio/multicast. +Далее закрывает endpoints с учётом UDP/TCP, db_sync, STCP/REALITY, ETCP и pending pings, +routing/media/router/firewall, сети, config, пулы и оставшиеся подписки. +Списки сокетов и соединений обходятся заново после закрывающих callbacks. +Deferred callbacks выкачиваются, пока UASYNC жив. + +Общая SQLite закрывается последней. Caller обязан завершить заимствованные +statements/backups до destroy. BUSY логируется, close_v2 откладывает физическое +освобождение БД до закрытия оставшихся SQL-объектов; instance после возврата уже недействителен. +Ни logger процесса, ни UASYNC destroy ядра не уничтожает. + +`utun_instance_diagnose_leaks` печатает sys/info счётчики и простые sanity-проверки +списков. Надпись POTENTIAL LEAK о живом ресурсе до cleanup не доказывает утечку; +проверка произвольных повреждённых указателей не гарантируется. +`instance_find_conn` ищет node_id в основном ETCP и старом TCP-реестре, возвращает +заимствованный указатель либо NULL, включая NULL instance; готовность UP/READY не обещает. + +Основные поля: `name[MAX_CONN_NAME_LEN]` (64), собственный config, node_id/ключи, +заимствованный ua, три *_started и running, topo_groups/SQLite, conn_mgr (у UTUN-группы), +etcp_sockets (UDP/TCP), stcp_servers (список), config_conn_handles, pools и networks. +Системные маршруты принадлежат отдельному `tun_routes`, а не списку config. +Подробные поля/типы описаны в заголовке. + +Проверки и платформенные ограничения фиксируются в +[utun_instance_audit_findings.txt](../doc/utun_instance_audit_findings.txt). From 44103038c0fa02c75147eb44cd1d3169f4445833 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:45:46 +0100 Subject: [PATCH 36/45] Preserve selected global log path and verify database owner reload --- src/utun_instance.c | 2 +- tests/test_instance_contract.c | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/utun_instance.c b/src/utun_instance.c index 41086941..ed354ce5 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -371,7 +371,7 @@ struct UTUN_INSTANCE* utun_instance_create_from_config(struct UASYNC* ua, struct DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "instance create requires uasync and config"); return NULL; } - if (config->global.log_file[0]) { + if (config->global.log_file[0] && !g_debug_config.file_output) { snprintf(g_config_log_path, sizeof(g_config_log_path), "%s", config->global.log_file); debug_set_output_file(g_config_log_path); } diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index 8c4a5b89..e0fa3797 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -202,13 +202,42 @@ static void test_headless(struct UASYNC* ua) { DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS chat-only lifecycle: three headless ports/clients, timer faults, retained SQLite and malformed binds"); } +static void test_database_and_logger(struct UASYNC* ua) { + char root[] = "/tmp/utun-instance-owners-XXXXXX"; assert(mkdtemp(root)); + char path[256], db_a[256], db_b[256], log_a[256], log_b[256], extras[1024], file[512]; + snprintf(path, sizeof(path), "%s/config", root); snprintf(db_a, sizeof(db_a), "%s/one/deep", root); + snprintf(db_b, sizeof(db_b), "%s/two", root); snprintf(log_a, sizeof(log_a), "%s/first.log", root); + snprintf(log_b, sizeof(log_b), "%s/second.log", root); + snprintf(extras, sizeof(extras), "[global]\ndb_path=%s\nlog_file=%s\n", db_a, log_a); + write_config(path, "same owner", "", extras); + struct UTUN_INSTANCE* inst = utun_instance_create(ua, path); assert(inst && !inst->core_started); + snprintf(file, sizeof(file), "%s/chats.db", db_a); + assert(!strcmp(sqlite3_db_filename(inst->topo_sqlite_db, "main"), file)); + assert(sqlite3_exec(inst->topo_sqlite_db, "CREATE TABLE instance_contract(value); INSERT INTO instance_contract VALUES(7)", + NULL, NULL, NULL) == SQLITE_OK); + snprintf(extras, sizeof(extras), "[global]\ndb_path=%s\nlog_file=%s\n", db_b, log_b); + write_config(path, "same owner", "", extras); + inst = utun_instance_reload(inst, ua, path); assert(inst && !inst->core_started && !inst->running); + snprintf(file, sizeof(file), "%s/chats.db", db_b); + assert(!strcmp(sqlite3_db_filename(inst->topo_sqlite_db, "main"), file)); + assert(!strcmp(g_debug_config.output_file, log_a)); // уже открытый общий logger сохраняет первый путь + utun_instance_destroy(inst); + assert(!strcmp(g_debug_config.output_file, log_a) && !debug_reopen_log()); // config освобождён, путь остаётся жив + debug_disable_file_output(); + snprintf(file, sizeof(file), "%s/chats.db", db_a); assert(!unlink(file)); + snprintf(file, sizeof(file), "%s/chats.db", db_b); assert(!unlink(file)); + assert(!unlink(path) && !unlink(log_a) && !rmdir(db_a) && !rmdir(db_b)); + snprintf(file, sizeof(file), "%s/one", root); assert(!rmdir(file) && !rmdir(root)); + DEBUG_INFO(DEBUG_CATEGORY_SYS, "PASS db_path restart, recursive directory, stopped state and logger path after config destruction"); +} + int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); test_callbacks(); size_t baseline = u_get_allocated_count(); struct UASYNC* ua = uasync_create(); assert(ua); - test_creation(ua); test_reload(ua); test_headless(ua); + test_creation(ua); test_reload(ua); test_headless(ua); test_database_and_logger(ua); uasync_poll(ua, 0); // удалённые heap-таймеры освобождаются при следующем poll debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_INFO); uasync_print_resources(ua, "instance contract before owner destroy"); From a58f3516ab6a622be0915dac7dda3e52ada7ab82 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:45:46 +0100 Subject: [PATCH 37/45] Clarify first-opened file logger ownership across instances --- src/utun_instance_doc.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utun_instance_doc.md b/src/utun_instance_doc.md index 666cd23d..0f1cad8e 100644 --- a/src/utun_instance_doc.md +++ b/src/utun_instance_doc.md @@ -41,8 +41,9 @@ uasync_destroy(ua, 0); Все возвращают NULL при ошибке и освобождают частично созданные ресурсы. При `create_from_config` ключи должны быть заданы корректно; производный node_id проверяется и может исправляться в модели даже при последующей ошибке. -`log_file` и уровни debug меняют logger всего процесса, включая неудачный create. -Путь лога копируется в постоянный буфер, чтобы пережить освобождение config. +Уровни debug меняют logger всего процесса, включая неудачный create. +`log_file` выбирает путь, только если файловый logger ещё не открыт; открытый сохраняет +первый путь. Выбранная строка копируется в постоянный буфер, чтобы пережить config. Create уже выводит идентичность X25519/Ed25519, создаёт индекс сетей и соединений, пулы, routing, обязательные UDP/TCP listeners, топологию/SQLite, мониторинг и From 0d6d1aeb191854b15fca5641000656c4641fe4d7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:51:31 +0100 Subject: [PATCH 38/45] Restart instances with duplicate server names to preserve all endpoints --- src/utun_instance.c | 11 +++++++++++ tests/test_instance_contract.c | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src/utun_instance.c b/src/utun_instance.c index ed354ce5..02b272e4 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -1031,6 +1031,16 @@ static int instance_servers_equal(const struct CFG_SERVER* a, const struct CFG_S return !a && !b; } +/* Parser сохраняет повторные server-секции отдельно; для них поимённый partial reload неоднозначен. */ +static int instance_server_names_unique(const struct CFG_SERVER* servers) { + for (const struct CFG_SERVER* a = servers; a; a = a->next) + for (const struct CFG_SERVER* b = a->next; b; b = b->next) + if (!strcmp(a->name, b->name)) { + DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "duplicate server name '%s': reload requires full restart", a->name); return 0; + } + return 1; +} + static int instance_routes_equal(const struct CFG_ROUTE_ENTRY* a, const struct CFG_ROUTE_ENTRY* b) { while (a && b) { if (a->netmask != b->netmask || a->ip.family != b->ip.family || @@ -1098,6 +1108,7 @@ struct UTUN_INSTANCE* utun_instance_reload(struct UTUN_INSTANCE* instance, struc struct utun_config* previous = instance->config; int servers_changed = !instance_servers_equal(previous->servers, next->servers); int full = !instance_globals_equal(&previous->global, &next->global) || + !instance_server_names_unique(previous->servers) || !instance_server_names_unique(next->servers) || !instance_routes_equal(previous->route_subnets, next->route_subnets) || !instance_routes_equal(previous->my_subnets, next->my_subnets) || (next->global.auto_sockets != CFG_AUTO_SOCKETS_OFF && servers_changed); diff --git a/tests/test_instance_contract.c b/tests/test_instance_contract.c index e0fa3797..eff126ba 100644 --- a/tests/test_instance_contract.c +++ b/tests/test_instance_contract.c @@ -149,6 +149,12 @@ static void test_reload(struct UASYNC* ua) { write_config(path, "same", one, ""); assert(utun_instance_reload(inst, ua, path) == inst && endpoint_count(inst) == 1); assert(!endpoint(inst, "tcp")->is_tcp && !stcp_server_list_count(inst)); + const char* duplicates = "[server:shared]\naddr=127.0.0.1:0\ntype=private\n" + "[server:shared]\naddr=127.0.0.1:0\ntype=private\n"; + write_config(path, "same", duplicates, ""); + inst = utun_instance_reload(inst, ua, path); assert(inst && inst->core_started && endpoint_count(inst) == 2); + write_config(path, "same", one, ""); + inst = utun_instance_reload(inst, ua, path); assert(inst && inst->core_started && endpoint_count(inst) == 1); sqlite3_stmt* statement = NULL; assert(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT 1", -1, &statement, NULL) == SQLITE_OK); utun_instance_stop(inst); assert(!inst->running); From e577daf6abff7141255aa10551af822dbb240984 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:51:32 +0100 Subject: [PATCH 39/45] Describe conservative reload for duplicate and resolved IPv6 selectors --- src/utun_instance_doc.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utun_instance_doc.md b/src/utun_instance_doc.md index 0f1cad8e..6af9be2b 100644 --- a/src/utun_instance_doc.md +++ b/src/utun_instance_doc.md @@ -108,7 +108,8 @@ my_subnet/route_subnet, а также server-конфига в automatic реж Сюда входят идентичность, имя, db_path, TUN/NAT/proxy, control, keepalive и режим сокетов. Горячими global-полями считаются firewall, уровни debug и STUN. Списки сравниваются по содержимому; различия порядка и padding модели могут -консервативно выбрать restart. +консервативно выбрать restart. Повторяющиеся server-имена также выбирают полный путь, +поскольку parser сохраняет такие секции отдельно и поимённая замена неоднозначна. Полный путь уничтожает старое ядро, создаёт новое из уже разобранной модели и восстанавливает прежние `core_started/utun_started/chat_started/running`. @@ -120,6 +121,8 @@ my_subnet/route_subnet, а также server-конфига в automatic реж сопоставляются по имени `[server:name]` и всем параметрам: адрес/порт/IPv6 scope, UDP/TCP, интерфейс, mark/FIB, MTU, type, REALITY/SOCKS, IPv6 mode и only_local. Неизменённый endpoint сохраняет socket ID, generation и выбранный ОС порт при port=0. +IPv6 temporary/permanent разрешается при создании и меняет адрес в модели config; +повторный reload такого селектора может консервативно пересоздать endpoint. Удаление TCP закрывает listener и TCP-модель, UDP использует собственный teardown. Probe и endpoints других владельцев не удаляются только из-за отсутствия в server-конфиге. From 85e87cc9f8622a805addae8c928a97793db9ac3b Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:53:13 +0100 Subject: [PATCH 40/45] Read radio hop list through aligned storage --- src/radio/radio.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/radio/radio.c b/src/radio/radio.c index 4cd6d35e..1a24a6b1 100644 --- a/src/radio/radio.c +++ b/src/radio/radio.c @@ -342,7 +342,8 @@ static void radio_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, RADIO_ID, (unsigned long long)group_id, hop_count, payload_len, from_conn->log_name); return; } - const uint64_t* hops = (const uint64_t*)(p + RADIO_OFF_HOPLIST); + uint64_t hops[RADIO_MAX_HOPS]; // wire hop-list может начинаться с невыровненного адреса + memcpy(hops, p + RADIO_OFF_HOPLIST, (size_t)hop_count * sizeof(hops[0])); const uint8_t* opus = p + RADIO_OFF_HOPLIST + (size_t)hop_count * 8; int opus_len = (int)(payload_len - RADIO_OFF_HOPLIST - (size_t)hop_count * 8); if (opus_len > RADIO_MAX_OPUS) { ctx->c_bad_dropped++; return; } From 4219d5ce1d6556d27a415cfa1be21f7bdb7bc0ab Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:54:13 +0100 Subject: [PATCH 41/45] Specify reload behavior for repeated server sections in public header --- src/utun_instance.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utun_instance.h b/src/utun_instance.h index 39924b58..9617c6be 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -306,7 +306,7 @@ struct UTUN_INSTANCE { struct UTUN_INSTANCE* utun_instance_create(struct UASYNC* ua, const char* config_file); /* Как create, но без updater и файлового parse: config должен содержать корректные ключи. * Успех передаёт владение config ядру; при NULL config остаётся caller (node_id мог быть исправлен). - * log_file и debug меняют общий logger процесса даже при последующем отказе создания. */ + * debug меняет общий logger процесса даже при отказе; log_file выбирается только до открытия файлового logger. */ struct UTUN_INSTANCE* utun_instance_create_from_config(struct UASYNC* ua, struct utun_config* config); /* Как create из ASCIIZ config_text; строка заимствована лишь на вызов. Использует mkstemp в /tmp, * updater и файловый parse, затем unlink. Нужна доступная ФС; ошибки дают NULL. */ @@ -329,7 +329,8 @@ void utun_service_stop(struct UTUN_INSTANCE* instance); int utun_instance_init(struct UTUN_INSTANCE *instance); /* Перечитать config_file через updater; ua должен совпадать с instance->ua. Всегда использовать результат. * Ошибка подготовки сохраняет прежний указатель. Смена негорячих global/подсетей либо automatic servers - * пересоздаёт ядро с прежними core/UTUN/chat/running; ошибка после destroy возвращает NULL. + * пересоздаёт ядро с прежними core/UTUN/chat/running; повторные server-имена также требуют restart. + * Ошибка после destroy возвращает NULL. * Ручные endpoints обновляются по имени и всем параметрам, неизменённые сохраняются (включая port=0). * Частичный reload не транзакционный: отказ endpoint логируется, новый config сохраняется для следующего retry. */ struct UTUN_INSTANCE *utun_instance_reload(struct UTUN_INSTANCE *instance, struct UASYNC *ua, const char *config_file); From 8640b45e99afb48efdeacc189432d287672df6d3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 08:57:02 +0100 Subject: [PATCH 42/45] Record instance lifecycle fixes, contracts and sanitizer audit evidence --- doc/utun_instance_audit_findings.txt | 99 ++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 doc/utun_instance_audit_findings.txt diff --git a/doc/utun_instance_audit_findings.txt b/doc/utun_instance_audit_findings.txt new file mode 100644 index 00000000..de8bd3c4 --- /dev/null +++ b/doc/utun_instance_audit_findings.txt @@ -0,0 +1,99 @@ +Аудит utun_instance — 26.10.2026 + +Объём +Полностью прочитаны utun_instance.c/.h и прежний utun_instance_doc.md; сверены +вызовы конструкторов/start/stop/reload из CLI/встраивания и связанные механизмы +ETCP/NCD/STCP, sockets, logger, очередей, SQLite и независимых сервисов. +Заголовок и документ переписаны по фактическому коду. Core-разделы +doc/service_lifecycle.md согласованы; весь поперечный документ остаётся [~]. +Это не полный аудит реализаций chat/call/radio/stream или GUI/Android. + +Исправления +- Activity/peer-sleep подписки удерживают снятые записи до выхода из внешней + рассылки. Безопасны удаление себя/соседа, вложенное событие и добавление новой + подписки. Повтор fn+arg не дублируется; более новая эффективная activity + отменяет остаток старой рассылки. OOM обеих подписок логируется. +- Конструкторы проверяют UASYNC/config/path до побочных действий; общий путь + создания освобождает частичные ресурсы, сохраняя caller config при отказе. + Проверяются crypto derivation, запись/close временного файла, создание каталогов + и индекс сетей. Старый TCP-реестр индексируется по node_id, а не указателю link. +- Выбранный путь общего logger копируется в постоянный буфер; освобождение config + больше не оставляет заимствованную строку в logger. Уже открытый файловый logger + сохраняет первый путь при создании/reload другого instance. +- Reload готовит config/firewall/networks до изменения ресурсов. Смена владельцев + (в том числе db_path, control, TUN/NAT/proxy), подсетей или automatic server-конфига + пересоздаёт ядро с прежними core/UTUN/chat/running. Повторные имена server также + выбирают полный путь, сохраняя отдельные секции parser без неоднозначного matching. +- Manual reload сравнивает все server-параметры и IPv6 scope по конфигурации, + сохраняя неизменённые sockets/ephemeral ports. TCP listener закрывается вместе + с правильной TCP-моделью; UDP создаётся/удаляется своим API. Устранён лог через + освобождённое имя. Probe и endpoints других владельцев не удаляются по отсутствию + server-секции. Конфигурационные clients обновляются также без UTUN; неизменённые + handles/таймеры сохраняются, копии не ссылаются на освобождённый config. +- Destroy обходит sockets/connections от свежей головы после callbacks, + освобождает старые TCP queue entries и оставшиеся transport-подписки. + Повторный destroy/start/reload при разрушении отклоняется; SQLite close ошибки + видны, BUSY завершается close_v2 до финализации заимствованных SQL-объектов. +- Три headless bind разбираются одинаково: IPv4 literal, полный decimal port + 0..65535. Port=0 выбирает ОС; плохое значение не открывает порт по умолчанию. + Отказ listener не фатален для чата, частичный контекст очищается. +- Call/radio headless stop освобождает контекст/кодеки, снимает клиентов с UASYNC + и отменяет cleanup/mix таймеры. Их отказы при старте дают rollback. +- UBSan нашёл невыровненный PCM после 11-байтового headless заголовка и uint64_t + hop-list в radio_recv. Оба читаются через выровненное хранилище; тест рации также + использует memcpy вместо int16_t* к невыровненному payload. Wire не менялся. +- Из заголовка удалено объявление несуществующего utun_instance_run; poll принадлежит + caller. Уточнены владение, состояния, ошибки init/reload, подписки и диагностика. + +Контракты и ограничения +Все lifecycle операции выполняются в потоке своего UASYNC, stop/destroy/reload — +вне callbacks останавливаемых модулей. UASYNC принадлежит caller и живёт до конца +очистки. Core start при отказе сохраняет подготовленные ресурсы для retry/destroy; +init при отказе позднего сервиса сохраняет ранее запущенные сервисы. +Create уже выполняет I/O и создаёт listeners/SQLite/router/proxy/monitoring. +Create_from_str требует временного файла /tmp и не является чистым buffer parser. +Logger общий для процесса; его изменения и config updater не входят в транзакцию reload. + +Partial reload не атомарен: отказ endpoint сохраняет новый config и отсутствие +endpoint для следующей попытки. Подготовка clients при ошибке сохраняет прежние +самостоятельные handles. Full reload возвращает NULL, если новое ядро не удалось +создать/запустить после уничтожения старого; caller использует возвращённый указатель. +Порядок списков/padding модели может консервативно выбрать restart. IPv6 selector +temporary/permanent меняет адрес в модели при разрешении, поэтому повторный reload +может консервативно пересоздать такой endpoint. +Caller завершает borrowed statements/backups; close_v2 не разрешает использовать +уничтоженный instance. Живые ресурсы в diagnose_leaks сами по себе не доказывают утечку. +Отменённые heap-таймеры UASYNC освобождаются при следующем poll, а pool cache — при +уничтожении UASYNC; тест сверяет active/counters и общий баланс после обоих шагов. + +Проверки +- Полная обычная сборка PASS: /tmp/utun-instance-audit-build-final.log. +- Первый полный check.sh: 196/196 PASS, 0 failed/0 skipped; proxy9/9, burst1/1, + load8/8, 134217728 байт, 189с; /tmp/utun-instance-audit-final-check.log. + Он предшествовал последней правке выравнивания radio hop-list. +- Профиль после исправлений: test_instance_contract, test_services, test_ncd_config, + test_tun_route, test_etcp_transport, test_stcp, test_node_conn_direct, + test_call_headless, test_radio_headless PASS; три изменённых адресных теста + повторены в /tmp/utun-instance-audit-final-test_*.log. +- Новый test_instance_contract проверяет nested callbacks/OOM, constructor ownership, + правильный TCP queue index, неизменённые ephemeral endpoints, malformed config, + отказ network queue/create endpoint с retry, UDP/TCP замену и удаление listener, + duplicate server names, full reload с сохранением stopped/core-only состояния, + SQLite BUSY/finalize, другой UASYNC, db_path restart, recursive mkdir и жизнь + logger path после освобождения config. Headless: три port=0, реальные TCP-клиенты, + stop/start с сохранением SQLite, три timer faults и malformed binds. +- ASAN/UBSan/LSan 11/11 PASS, detect_leaks=1, halt_on_error=1: + те же девять instance/transport/headless тестов плюс config_parser/config_updater. + Итог: /tmp/utun-instance-audit-asan-results-complete.log; отдельные логи + /tmp/utun-instance-audit-asan-complete-test_*.log. + Отдельная копия /tmp/utun-config-instance-asan-20261026, все lib/src/Opus + инструментированы согласованно. Встроенный Opus не перекрывает CFLAGS в этой + тестовой копии; рабочая normal-сборка с sanitizer-объектами не смешивалась. + Сборки: /tmp/utun-instance-audit-asan-build.log, + /tmp/utun-instance-audit-asan-core-final.log и ...-asan-tests-build.log. + Ранние наборы 9/11 и 10/11 выявили описанные alignment defects; окончательный + набор после исправления обеих причин полностью PASS. +- git diff --check PASS. Нативные Windows/FreeBSD сборки/runtime, GUI/Android и + аппаратные TUN/media в этом этапе не запускались; Linux TUN lifecycle проверен mock-тестом. + +Окончательный штатный check.sh после последней правки: результат дописывается ниже. From 435ca204fe6327944831fdff0a5fc70fdad3e06f Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 09:00:55 +0100 Subject: [PATCH 43/45] Clarify that repeated core start preserves the application running flag --- src/utun_instance.h | 4 ++-- src/utun_instance_doc.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/utun_instance.h b/src/utun_instance.h index 9617c6be..7314ed45 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -315,8 +315,8 @@ struct UTUN_INSTANCE* utun_instance_create_from_str(struct UASYNC* ua, const cha * Выполняет drain отложенных callbacks и join media workers; вызывать вне callbacks и без параллельных обращений. * Заимствованные SQLite statements/backup должны быть закрыты caller; BUSY логируется и переводится в close_v2. */ void utun_instance_destroy(struct UTUN_INSTANCE* instance); -/* Запустить репликацию, NCD/keepalive, [client], control и время; без вступления в группы. Успех: running=core_started=1. - * Повтор успешного start — 0. Ошибка — -1; уже подготовленные ресурсы остаются для retry/destroy. +/* Запустить репликацию, NCD/keepalive, [client], control и время; без вступления в группы. Первый успех: running=core_started=1. + * При core_started возвращает 0 без изменения running. Ошибка — -1; подготовленные ресурсы остаются для retry/destroy. * Отказ NTP/STUN не фатален. NULL/разрушаемый instance отклоняется. */ int utun_core_start(struct UTUN_INSTANCE* instance); /* Запустить UTUN-группу, TUN/NAT по конфигу и отдельные запросы к [client]. Нужны core_start и топология. diff --git a/src/utun_instance_doc.md b/src/utun_instance_doc.md index 6af9be2b..f1d9977b 100644 --- a/src/utun_instance_doc.md +++ b/src/utun_instance_doc.md @@ -28,7 +28,8 @@ uasync_destroy(ua, 0); Цикл запускает приложение; функции `utun_instance_run` нет. `utun_instance_stop` только снимает `running` и вызывает wakeup. Это не stop сервисов. -При общем UASYNC решение о продолжении poll принимает его владелец. +Повторный core_start при уже установленном core_started не поднимает running после stop. +Решение о продолжении poll принимает владелец UASYNC, в том числе при нескольких instance. ## Конструкторы @@ -60,7 +61,7 @@ create. Недоступность ICMP raw socket по правам/подде | API | Ресурсы и результат | |---|---| -| `utun_core_start` | db_sync, NCD/keepalive, постоянные транспорты `[client]`, control, время, broadcast/radio и STUN. Успех задаёт `core_started=running=1` | +| `utun_core_start` | db_sync, NCD/keepalive, постоянные транспорты `[client]`, control, время, broadcast/radio и STUN. Первый успех задаёт `core_started=running=1` | | `utun_service_start` | Требует core и топологию. Создаёт UTUN-группу, DATA binding, опциональные TUN/маршруты/NAT и отдельные групповые запросы к `[client]` | | `chat_service_start` | Объявлен в chat_core.h. Требует core и SQLite. Поднимает CHAT-синхронизацию, stream/media/DM/member_files/call/radio-call, headless и LAN | | `utun_instance_init` | Последовательно core, затем UTUN при наличии топологии, затем чат при `chatserver_enabled` | From bb6ed1723fc0a6b0f3828ba2056d6eccc9776852 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 09:04:30 +0100 Subject: [PATCH 44/45] Close instance audit with full integration and sanitizer results --- doc/tasks.md | 10 ++++++++++ doc/utun_instance_audit_findings.txt | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/doc/tasks.md b/doc/tasks.md index 136864e4..5b0f33ff 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -19,6 +19,16 @@ /tmp/stream-member-fix-full-check.log. Полный аудит остальных контрактов stream этим не закрывается. Подробности: [stream_audit_findings.txt](stream_audit_findings.txt). +[+] **utun_instance: завершить аудит владения, lifecycle и reload** — завершено 26.10.2026 после config_parser. + Исправлены вложенные подписки, отказ конструкторов, индекс TCP-реестра, жизнь пути общего logger, + selective/full reload, TCP listener teardown, освобождение очередей/подписок и SQLite BUSY. + Headless start/stop освобождает контексты/клиентов/таймеры; PCM и radio hop-list читаются выровненно. + Заголовок и документ полностью сверены; core-разделы service_lifecycle.md согласованы, общий [~] сохранён. + Новый test_instance_contract покрывает callbacks/OOM, reload/fault/retry, duplicate servers, database/logger + ownership и headless lifecycle. Сборка PASS; check.sh196/196 PASS,0 skipped,proxy9/9,burst1/1,load8/8,173с. + ASAN/UBSan/LSan11/11 PASS,detect_leaks=1; ограничения partial reload и нативных платформ описаны + в [utun_instance_audit_findings.txt](utun_instance_audit_findings.txt). + [+] **config_parser: завершить сверку и строгий разбор значений** — завершено 26.10.2026. Исправлены IPv6 /32, control /0 и порядок ip/port, числовые границы/таймеры, storage overflow, потерянные ошибки обработчиков и памяти, повторное включение proxy, HEX и диагностический dump секретов. diff --git a/doc/utun_instance_audit_findings.txt b/doc/utun_instance_audit_findings.txt index de8bd3c4..accf8788 100644 --- a/doc/utun_instance_audit_findings.txt +++ b/doc/utun_instance_audit_findings.txt @@ -96,4 +96,9 @@ Caller завершает borrowed statements/backups; close_v2 не разре - git diff --check PASS. Нативные Windows/FreeBSD сборки/runtime, GUI/Android и аппаратные TUN/media в этом этапе не запускались; Linux TUN lifecycle проверен mock-тестом. -Окончательный штатный check.sh после последней правки: результат дописывается ниже. +Окончательный штатный check.sh после последней правки: 196/196 PASS, +0 failed/0 skipped, proxy9/9, burst1/1, load8/8, 134217728 байт, 173с. +Лог /tmp/utun-instance-audit-complete-check.log. После этого изменены только комментарии +о повторном core_start (он сохраняет running) и итоговые документы/чек-лист. +utun_instance.h и utun_instance_doc.md отмечены [V]; ограничения общего документа +service_lifecycle.md и прежние записи остальных модулей сохранены. From 8e74619501a7ca097e5fe60abce17360a20c4b06 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 09:18:15 +0100 Subject: [PATCH 45/45] Make chat joins atomic and test invitation lifecycle failures --- doc/chat_audit_findings.txt | 128 +++ doc/service_lifecycle.md | 9 + doc/tasks.md | 15 + lib/u_async.c | 2 +- src/Makefile.am | 4 + src/chat/chat_channel.c | 45 +- src/chat/chat_core.h | 5 +- src/chat/chat_event.h | 2 +- src/chat/chat_headless_control.c | 29 +- src/chat/chat_join.h | 4 +- src/chat/chat_join_port.c | 127 +++ src/chat/chat_join_port.h | 32 + src/chat/chat_sync.c | 1006 ++++++----------- src/chat/chat_sync.h | 47 +- src/chat/invite_build.c | 2 +- src/chat/invite_link.c | 346 +++--- src/chat/invite_link.h | 54 +- src/chat/member_sync.c | 20 +- src/chat/member_sync.h | 6 +- src/utun_instance.c | 1 + tests/Makefile.am | 13 + tests/stream_netns/node.c | 3 +- tests/test_chat_join_e2e.c | 4 +- tests/test_chat_join_faults.c | 305 +++++ tests/test_chat_sync_schedule.c | 3 + tests/test_dm_invite.c | 5 +- tests/test_invite_codec.c | 101 ++ .../main/java/com/utun/chat/MainActivity.kt | 3 +- .../java/com/utun/chat/data/ChatRepository.kt | 3 +- .../java/com/utun/chat/data/InviteLink.kt | 158 +-- .../main/java/com/utun/chat/data/NativeLib.kt | 12 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 21 +- .../headless/headless_control.c | 10 +- .../jni_bridge/android_jni_bridge.c | 96 +- .../jni_bridge/android_jni_bridge.h | 8 - .../libutun_lite/instance_lite.c | 21 + .../libutun_lite/instance_lite.h | 3 + tools/chatgui/src/invite_link.cpp | 229 +--- tools/chatgui/src/invite_link.h | 7 - tools/chatgui/src/inviteby.cpp | 17 +- tools/chatgui/src/inviteby.h | 3 +- tools/chatgui/src/joindialog.cpp | 18 +- tools/chatgui/src/joindialog.h | 3 +- tools/chatgui/transport/gui_bridge.h | 4 +- tools/chatgui/transport/gui_bridge_impl.cpp | 31 +- tools/chatgui/transport/utun_node.cpp | 1 + 46 files changed, 1563 insertions(+), 1403 deletions(-) create mode 100644 doc/chat_audit_findings.txt create mode 100644 src/chat/chat_join_port.c create mode 100644 src/chat/chat_join_port.h create mode 100644 tests/test_chat_join_faults.c create mode 100644 tests/test_invite_codec.c diff --git a/doc/chat_audit_findings.txt b/doc/chat_audit_findings.txt new file mode 100644 index 00000000..471d8e0c --- /dev/null +++ b/doc/chat_audit_findings.txt @@ -0,0 +1,128 @@ +Chat: исправление приглашений и проверка контрактов — 26.10.2026 + +Граница работы + +В начале аудита по /home/user/utun-doc-audit-checklist.txt установлены два дефекта: +успех invite_link_decode без узла подключения и разыменование непроверенного +выделения cm_invite_wrap. После согласования архитектуры исправлен весь путь +команды JOIN; добавлены тесты контрактов, отказов и жизненного цикла. +Полный аудит всех модулей src/chat не завершён. chat_sync.h остаётся [~]. + +Реализация + +1. invite_link.c/.h — единый C-кодек для headless, Qt и Kotlin/JNI. + Только версия 0x03, строгий канонический base64 с padding, полный разбор, + одинаковый ключ во всех блоках, 1..32 адреса, family=4/6, proto=1/2/6, + ненулевой порт, согласованный REALITY. Канал — 1..INT64_MAX, join_key — + любой ненулевой uint64. Нулевой первый байт публичного ключа допустим. + Строки ограничены, превышение длины отклоняется; скрытый NUL не принимается. + Успех decode полностью определяет InviteData; ошибка оставляет out прежним. + Encode и сериализация адресов также не меняют выход при отказе. + Максимум с NUL — INVITE_LINK_MAX=1552: 32 IPv6, REALITY и пароль127 байт. + Из frontend удалены отдельные реализации декодирования; Qt/Kotlin только + преобразуют результат C-кодека в своё представление. + +2. chat_sync_join_link/offer_link выполняются только в uasync и принимают ссылку. + Промежуточные chat_invite/addrs_data/cm_invite_wrap удалены. NCD bootstrap + строится целиком, включая REALITY; ошибка освобождает все временные адреса. + Общий объект локальной операции хранит роль, фазу, request/attempt, NCD и deadline. + PREPARE не реагирует на UP до установки handle; затем уже работающий транспорт + запускает обмен без ожидания повторного UP. Другой владелец сохраняет свой NCD. + BUSY относится к локальной операции; чужие JOIN обслуживаются независимо. + +3. INFO_REQ/RESP, REQUEST, READY и ERROR несут attempt_id:8BE сразу после type. + JOIN_READY содержит также ровно8 байт ожидаемого group_id. Ответ сопоставляется + с peer/group/attempt/phase; повторное использование ссылки создаёт новый attempt. + Connection-узел хранит отдельные waiters попыток; повторный запрос не затирает + токен прежнего waiter. Есть deadline и ограничение8 waiters на peer; просроченные + удаляются при новых запросах и штатном обновлении кеша. + CHANNEL_INVITE переносит предложение и join_key; принимающий создаёт свой attempt. + Регистрация ключа A↔C и подписанные записи участников не используют attempt_id. + Сетевой формат JOIN изменён без поддержки старых узлов, согласно devel-контракту. + +4. chat_join_port — отдельная удерживаемая ссылка frontend на вход команд. + Submit копирует ссылку, резервирует весь posted_task до принятия команды и + публикует без дополнительного выделения памяти. Закрытие и публикация защищены + mutex. Закрытый порт не содержит instance/uasync и не оживает при restart. + Close отменяет ожидающие команды; уже извлечённый uasync batch исполнит только + освобождение. Arg встроен в выделение task, повторного освобождения нет. + Qt и instance_lite публикуют/удерживают порт под собственным mutex; GUI/JNI + не читают chat_sync из внешнего потока. Chat stop закрывает приём до workers. + Request интерфейса не равен сетевому attempt. Headless использует request=0, + сохраняя собственную корреляцию JSON-команд отдельно от GUI. + +5. Завершение: отделение локальной операции, снятие таймеров, освобождение своего + handle, затем единое событие peer:8/result:4/channel:8/request:8 (native). + Обработчик события может сразу начать новую попытку. Ранний отказ core не даёт + события; мост завершает свою уже принятую команду этим отказом. Принятая core + попытка получает один результат, включая cancel/delete/stop. + Cancel не откатывает членство, которое уже сохранил другой узел, и не удаляет + принятый NCD snapshot или рабочие линки других владельцев. + +6. JOIN_INFO_RESP: подписанное описание и member/Merkle записываются в одной SQL + транзакции. Проверяется привязка connection-member к транспортному peer и + неизменность уже известных канальных ключей. Ошибка откатывает SQL и не создаёт + CHAT-группу. После commit публикуются runtime-ресурсы и уведомления member_sync. + Предыдущая семантика отложенной проверки неизвестного родителя сохранена; + успешный apply не называется подтверждением всей цепочки приглашений. + Ensure_channel_ready возвращает ошибку и сохраняет необходимый порядок: + группа → подписка → db_sync. При отказе убираются только новые runtime-ресурсы. + Проверенные данные после commit сохраняются для повторной подготовки. + Ключи приглашения, пароль и исходная ссылка не печатаются в новых логах. + +Тесты + +- test_invite_codec: 116342 проверки. Roundtrip1..32 адресов IPv4/IPv6/mixed/REALITY, + пароль127, SNI63, максимальная ссылка1551 байт; точный буфер и буфер на байт меньше, + сохранность выходов, усечения, алфавит всех256 байтов, старые версии, конфликт + ключей блоков, неизвестные/отсутствующие поля и границы значений. +- test_chat_join_faults: настоящий controller/NCD/SQLite, перехват только доставки + пакетов и точек отказа. Каждая аллокация подготовки, включая REALITY, отказы + transport/info/ready deadlines, send и NCD; повтор после отказа и чужое владение. + Запоздавшие ERROR/READY, чужой group/короткий READY, повтор результата, BUSY, + вложенный UP во время PREPARE и новый JOIN из обработчика завершения. + SQL-trigger и неверная подпись member откатывают канал; отказы group/db_sync/ + подписки после commit сохраняют проверенные данные и убирают runtime-ресурсы. + Узел обслуживает чужой JOIN во время своей попытки. Проверены queued/active + дубликаты request, producer другого потока, старый порт после restart, + stop между копированием аргументов и публикацией (condvar-барьер), stop внутри + уже извлечённого batch. После teardown число u_malloc-блоков возвращается к исходному. +- test_chat_join_e2e: настоящий обмен A!=C, A==C, неверный ключ. +- test_dm_invite: приглашение из DM и реальное вступление. +- test_services: независимый lifecycle сервисов. +- test_chat_sync_schedule: фикстуры приведены к наличию активной попытки/waiter; + подтверждается синхронизация пары после JOIN_READY без нового транспортного UP. +- Qt test_invite_links/test_local_invite_ui: ссылки и UI на общем кодеке. + +Результаты окончательных прогонов и ограничения приводятся ниже. + +Итоговые результаты + +Linux check.sh: 196/196 PASS, 0 failed, 0 skipped; proxy9/9, burst1/1, +load8/8, 134217728 проверенных байт, 190 секунд. +Лог: /tmp/chat-join-complete-check.log. +Отдельный финальный test_chat_join_faults с condvar-гонкой и вложенным UP PASS; +лог: /tmp/chat-join-race-final.log. Тот же вариант включён в окончательный check.sh. + +ASAN/UBSan/LSan: 6/6 PASS, detect_leaks=1, halt_on_error=1. +Кодек, отказы JOIN, настоящий E2E, DM invite, services и планировщик. +В изолированной копии /tmp/utun-chat-join-sanitizers согласованно инструментированы +lib/src/Opus. Правка флагов Opus выполнена только в копии: рабочая сборка не смешивается +с sanitizer-объектами. Сводка: /tmp/chat-join-sanitizer-results.log; +по каждому набору: /tmp/chat-join-sanitizer-test_*.log. + +Desktop CMake build PASS: /tmp/chat-join-desktop-verified-build.log. +Qt invite/local_invite: 2/2 PASS, /tmp/chat-join-desktop-verified-tests.log. +Android assembleDebug --offline PASS (JNI, Kotlin и APK; без установки на устройства): +/tmp/chat-join-android-verified-build.log. +Дополнительный старый Linux CMake-каркас Android headless не собрался из-за отсутствия +include speex/speex_echo.h в его собственном списке путей; этот отдельный каркас +не заменяет успешную APK-сборку и native Linux-фикстуры основного набора. +Windows/FreeBSD native build/runtime не выполнялись. git diff --check PASS. + +Промежуточные отказы проверок устранены: размер size_t и порядок _GNU_SOURCE в +instance_lite, старое объявление InviteDataC в JNI; затем фикстура планировщика +обновлена под обязательные waiter/активную попытку. Тесты runtime-отката выявили +зависимость db_sync от группы; требуемый порядок восстановлен, добавлена очистка +новой группы при отказе истории/подписки. Это покрыто отрицательными сценариями. +Исходные изменения аудита и итоговые правки не коммитились и не публиковались. diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 7c46ebbc..0438a1cd 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -110,6 +110,15 @@ TCP client/server владеют только своими bindings, listeners UTUN/CHAT-групп и транспорта. Waiters/таймеры снимаются до освобождения их владельцев. Остановка отдельного TCP proxy-модуля не уничтожает общие UDP/ICMP-контексты. +Команды JOIN из Qt/JNI используют удерживаемый `chat_join_port`: frontend копирует ссылку, +а состояние чата читает только callback в uasync. Close закрывает приём под mutex, отменяет +ожидающие команды и отделяет порт от instance. Ссылка frontend остаётся безопасной после stop; +новый запуск публикует новый порт. Команды старого запуска не обращаются к новому ядру. +При stop чата порт и активная попытка закрываются до ожидания media workers. В принятой команде +есть отдельный request_id интерфейса; сетевой JOIN сопоставляется по peer/group/attempt_id/phase. +Результат публикуется после очистки состояния и собственного NCD handle, поэтому callback может +начать следующий JOIN. Отмена ожидания не откатывает уже сохранённое удалённое членство. + SQLite открывается ядром один раз: `db_path/chats.db`, либо `:memory:`, если путь не задан. Остановка чата сохраняет каналы и сообщения в этой БД. В варианте `:memory:` данные сохраняются только до уничтожения ядра. diff --git a/doc/tasks.md b/doc/tasks.md index 5b0f33ff..87ba56c4 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -37,6 +37,21 @@ Полный check.sh195/195 PASS,0 skipped,proxy9/9,burst1/1,load8/8,134217728 байт,169с. Подробности, платформенные ограничения и логи: [config_parser_audit_findings.txt](config_parser_audit_findings.txt). +[+] **chat JOIN: исправить кодек, владение попыткой и покрыть нюансы тестами** — завершено 26.10.2026. + Общий C-кодек для Qt/JNI/headless, отдельные request/attempt IDs, защита от старых ответов, + потокобезопасный порт команд, целостный запуск и очистка до уведомления, SQL/runtime-откат. + Кодек116342 случаев; отказы памяти/таймеров/NCD/send, REALITY, SQL, повторные callbacks, + чужие владельцы и JOIN, детерминированная гонка stop/producer и отмена извлечённого batch — PASS. + check.sh196/196, proxy9/9, burst1/1, load8/8 (134217728 байт); ASAN/UBSan/LSan6/6, detect_leaks=1. + Desktop/Android APK build PASS, Qt invite/local UI2/2. JOIN wire изменён без совместимости со старыми узлами. + Подробности: [chat_audit_findings.txt](chat_audit_findings.txt). Коммит/push не выполнялись. + +[ ] **chat: продолжить аудит заголовков и документов** — проверка всего каталога не завершена. + Приглашения исправлены по согласованному плану: общий кодек, токены попыток, целостный запуск, + SQL/runtime-границы, единое завершение и отдельный потокобезопасный порт команд GUI/JNI. + Добавлены test_invite_codec и test_chat_join_faults; подробности и результаты: + [chat_audit_findings.txt](chat_audit_findings.txt). Остальные непроверенные пункты чек-листа сохраняются. + [+] **transport_layer: завершить аудит заголовков и документов** — завершено 26.10.2026. С учётом прежних этапов сверены все32 собственных заголовка и17 документов каталога плюс doc/stun_nat.md. Закрыты auto_socket/socket_monitor/SOCKS/call_ring/REALITY/STUN; исправлены владение endpoints/posts, diff --git a/lib/u_async.c b/lib/u_async.c index fa483fba..86b097d1 100644 --- a/lib/u_async.c +++ b/lib/u_async.c @@ -1867,7 +1867,7 @@ err_t uasync_cancel_post(struct UASYNC* ua, struct posted_task* task) { pthread_mutex_unlock(&ua->posted_lock); #endif if (found) u_free(task); - else DEBUG_ERROR(DEBUG_CATEGORY_SYS, "uasync_cancel_post: completion is not pending"); + else DEBUG_DEBUG(DEBUG_CATEGORY_SYS, "uasync_cancel_post: completion already taken or absent"); return found ? ERR_OK : ERR_FAIL; } diff --git a/src/Makefile.am b/src/Makefile.am index 67c64df9..d08d8fdc 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -185,6 +185,8 @@ utun_CORE_SOURCES = \ chat/chat_sync.c \ chat/chat_admin.c \ chat/chat_join.c \ + chat/chat_join_port.c \ + chat/chat_join_port.h \ chat/member_sync.c \ chat/merkle_sync.c \ chat/merkle_tree.c \ @@ -373,6 +375,8 @@ libutun_a_SOURCES = \ chat/chat_sync.c \ chat/chat_admin.c \ chat/chat_join.c \ + chat/chat_join_port.c \ + chat/chat_join_port.h \ chat/member_sync.c \ chat/merkle_sync.c \ chat/invite_link.c \ diff --git a/src/chat/chat_channel.c b/src/chat/chat_channel.c index 34fd6e27..a764047b 100644 --- a/src/chat/chat_channel.c +++ b/src/chat/chat_channel.c @@ -60,30 +60,33 @@ static int channel_sign_join(const uint8_t* ch_x25519, const uint8_t* ch_ed25519 /* ─── подготовка инфраструктуры канала (db_sync instance) ─── */ -void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id) { - struct chat_core_ctx* cc = CC(inst); - if (!cc || !cc->initialized || !ch_id || !ch_id[0]) return; - if (chat_feedback_ensure_channel(inst, ch_id) < 0) return; - if (si_find(inst, ch_id)) return; - if (topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return; - - char tbl_msg[80]; msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg)); - +int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id) { + struct chat_core_ctx* cc = inst ? CC(inst) : NULL; + if (!cc || !cc->initialized || !ch_id || !ch_id[0] || !inst->topo_groups) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel preparation without active chat/topology"); return -1; + } + if (chat_feedback_ensure_channel(inst, ch_id) < 0 || topo_node_sqlite_member_local_init(cc->db, ch_id) < 0) return -1; uint64_t gid = strtoull(ch_id, NULL, 10); - if (gid != 0 && inst->topo_groups && !topo_groups_find(inst->topo_groups, gid)) { - struct TOPO_GROUP* g = topo_groups_create_group(inst->topo_groups, gid, TOPO_GROUP_TYPE_CHAT, ch_id); - if (g) member_sync_subscribe_group(inst, g); + struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, gid); + int created_group = !group; + if (!group) group = topo_groups_create_group(inst->topo_groups, gid, TOPO_GROUP_TYPE_CHAT, ch_id); + if (!group || member_sync_subscribe_group(inst, group) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel group preparation failed ch=%s", ch_id); + if (group && created_group) topo_groups_remove_group(inst->topo_groups, gid); + return -1; } - struct DB_SYNC_INSTANCE* si = db_sync_instance_add(inst, tbl_msg, gid); - if (si) { - si_register(inst, si, ch_id); - db_sync_set_insert_cb(si, on_msg_inserted, inst); - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: channel ready ch=%s tbl=%s group=0x%016llx", - CC_ID, ch_id, tbl_msg, (unsigned long long)gid); - } else { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: db_sync_instance_add failed for ch=%s", - CC_ID, ch_id); + struct DB_SYNC_INSTANCE* si = si_find(inst, ch_id); + int created_sync = !si; + char table[80]; msg_table_name(ch_id, table, sizeof(table)); + if (!si) si = db_sync_instance_add(inst, table, gid); + if (!si) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "channel sync allocation failed ch=%s", ch_id); + if (created_group) topo_groups_remove_group(inst->topo_groups, gid); + return -1; } + if (created_sync) { si_register(inst, si, ch_id); db_sync_set_insert_cb(si, on_msg_inserted, inst); } + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "channel prepared ch=%s new_group=%d new_sync=%d", ch_id, created_group, created_sync); + return 0; } /* ─── создание канала ─── */ diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 6f62b9ff..3ad16c59 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -125,8 +125,9 @@ struct chat_channel_create { struct chat_create_auto_arg { struct UTUN_INSTANCE* inst; char name[128]; }; -// Подготовить локальные таблицы, группу и синхронизацию канала. Членства на других узлах не даёт. -void chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id); +/* Подготовить таблицы, группу и синхронизацию; 0/-1. Ошибка удаляет новые runtime-ресурсы, + * проверенные данные БД сохраняются. Членства на других узлах не даёт. */ +int chat_core_ensure_channel_ready(struct UTUN_INSTANCE* inst, const char* ch_id); // Создать канал по готовым ключам и метаданным req; req остаётся у вызывающего. void chat_core_create_channel(struct UTUN_INSTANCE* inst, struct chat_channel_create* req); diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index e333c9cc..b03a5664 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -24,7 +24,7 @@ extern "C" { // Типы событий (совпадают с GUI_EVT_* в gui_bridge.h для прямой совместимости) #define CHAT_EVT_MSG_RECEIVED 1 /* [ch_id_len:1][ch_id:var][author_node_id:8][initial_sync:1] */ -#define CHAT_EVT_CONNECT_RESULT 2 /* [node_id:8][result:4][channel_id:8] */ +#define CHAT_EVT_CONNECT_RESULT 2 /* [node_id:8][result:4][channel_id:8][request_id:8], native */ #define CHAT_EVT_NEW_PEER 3 /* [node_id:8] */ #define CHAT_EVT_CHANNEL_UPDATED 4 /* [ch_id_len:1][ch_id:var] */ #define CHAT_EVT_MEMBERS_CHANGED 5 /* [ch_id_len:1][ch_id:var] */ diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index 04ca5b24..2d2b0317 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -509,7 +509,7 @@ static void hc_invite_ready(void* arg, int result, const char* link) { } DEBUG_INFO(DEBUG_CATEGORY_HEADLESS, "headless: invite confirmed ch=%llu node=%016llx addrs=%d", (unsigned long long)d.channelId, (unsigned long long)d.nodeId, (int)d.addrCount); - char resp[1280]; + char resp[INVITE_LINK_MAX + 256]; snprintf(resp, sizeof(resp), "{\"link\":\"%s\",\"channel_id\":%llu,\"node_id\":\"0x%016llx\",\"addrs\":%d}", link, (unsigned long long)d.channelId, (unsigned long long)d.nodeId, (int)d.addrCount); @@ -571,7 +571,7 @@ static void hc_handle_invite_nodes(struct headless_client* cli, int id, const ch static void hc_handle_connect(struct headless_client* cli, int id, const char* json) { struct headless_control* hc = cli->hc; - char link[1024]; + char link[INVITE_LINK_MAX]; if (json_get_str(json, "link", link, sizeof(link)) < 0) { send_response(cli, id, NULL, "missing 'link' param"); return; } struct InviteData d; char err[256]; if (invite_link_decode(link, strlen(link), &d, err, sizeof(err)) < 0) { send_response(cli, id, NULL, err); return; } @@ -580,17 +580,8 @@ static void hc_handle_connect(struct headless_client* cli, int id, const char* j DEBUG_INFO((int)DEBUG_CATEGORY_HEADLESS, "headless: connect ch=%llu node=0x%016llx addrs=%d", (unsigned long long)d.channelId, (unsigned long long)d.nodeId, (int)d.addrCount); - uint8_t addrs_buf[2048]; - int addrs_len = invite_serialize_addrs(&d, addrs_buf, sizeof(addrs_buf)); - if (addrs_len <= 0) { send_response(cli, id, NULL, "serialize addrs failed"); return; } - - { char chstr[64]; snprintf(chstr, sizeof(chstr), "%llu", (unsigned long long)d.channelId); - chat_core_ensure_channel_ready(hc->inst, chstr); } - - chat_core_set_my_node_id(hc->inst, hc->inst->node_id); - - chat_sync_connect_from_invite(hc->inst, d.channelId, d.nodeId, d.pubkey, - addrs_buf, d.addrCount, addrs_len, d.password_len ? d.password : NULL, d.join_key); + int result = chat_sync_join_link(hc->inst, 0, link, strlen(link)); + if (result) { send_response(cli, id, NULL, "join could not start (see log)"); return; } char resp[512]; snprintf(resp, sizeof(resp), "{\"channel_id\":%llu,\"node_id\":\"0x%016llx\",\"addrs\":%d,\"connecting\":true}", @@ -647,8 +638,16 @@ static void hc_handle_invite_to(struct headless_client* cli, int id, const char* DEBUG_INFO((int)DEBUG_CATEGORY_HEADLESS, "headless: invite_to ch=%s node=0x%016llx addr=%s:%d proto=%d", ch, (unsigned long long)target_node_id, ip, port, proto); - chat_sync_invite_to_channel_with_addrs(hc->inst, ch, target_node_id, - pubkey_bin, addrs_data, 1, addrs_len); + struct InviteData target = { .channelId = strtoull(ch, NULL, 10), .join_key = 1, .addrCount = 1 }; + memcpy(target.pubkey, pubkey_bin, 32); target.addrs[0].family = addrs_data[1]; + target.addrs[0].proto = (uint8_t)proto; target.addrs[0].port = (uint16_t)port; + memcpy(target.addrs[0].address, addrs_data + 4, target.addrs[0].family == 4 ? 4 : 16); + char target_link[INVITE_LINK_MAX]; + if (sc_derive_node_id_from_pubkey(pubkey_bin) != target_node_id || + invite_link_encode(&target, NULL, target_link, sizeof(target_link)) < 0 || + chat_sync_offer_link(hc->inst, 0, target.channelId, target_link, strlen(target_link))) { + send_response(cli, id, NULL, "invitation could not start"); return; + } char resp[256]; snprintf(resp, sizeof(resp), "{\"channel_id\":\"%s\",\"node_id\":\"0x%016llx\",\"inviting\":true}", diff --git a/src/chat/chat_join.h b/src/chat/chat_join.h index 8181b32e..e9f9fb77 100644 --- a/src/chat/chat_join.h +++ b/src/chat/chat_join.h @@ -64,7 +64,9 @@ * invite-ключ как замену локальной таблицы участников. * * J <-> C: ETCP_ID_CHAT_SYNC, заголовок [service:1][group_id:8][type:1], payload - * описан в chat_sync.h. A <-> C: ETCP_RT_ID_JOIN через etcp_router с подписью + * описан в chat_sync.h. Для INFO_REQ/RESP, REQUEST, READY и ERROR payload начинается + * с attempt_id:8BE; C сохраняет его в waiter и возвращает в ответах. Это ID попытки, + * не join_key и не request_id интерфейса. A <-> C: ETCP_RT_ID_JOIN через etcp_router с подписью * и шифрованием; subcommands и payload описаны ниже. * * Модуль отвечает только за маршрутизируемую (etcp_router) часть: diff --git a/src/chat/chat_join_port.c b/src/chat/chat_join_port.c new file mode 100644 index 00000000..ae60e7fa --- /dev/null +++ b/src/chat/chat_join_port.c @@ -0,0 +1,127 @@ +#include "chat_join_port.h" +#include "chat_sync.h" +#include "invite_link.h" +#include "../utun_instance.h" +#include "../../lib/mem.h" +#include "../../lib/debug_config.h" +#include +#include + +struct join_command { + struct posted_task post; /* первый член: uasync освобождает всю команду */ + struct join_command* next; + struct chat_join_port* port; + uint64_t request, channel, peer, offer; + size_t len; + int completed; + char link[]; +}; +struct chat_join_port { + pthread_mutex_t lock; + unsigned refs; + uint64_t active_request; + struct UTUN_INSTANCE* inst; /* только callback в uasync обращается к instance */ + struct UASYNC* ua; /* жив до close; producer публикует под lock */ + struct join_command* commands; +}; + +struct chat_join_port* chat_join_port_create(struct UTUN_INSTANCE* inst) { + struct chat_join_port* p = u_calloc(1, sizeof(*p)); + if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join port allocation failed"); return NULL; } + if (pthread_mutex_init(&p->lock, NULL)) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join port mutex initialization failed"); u_free(p); return NULL; + } + p->refs = 1; p->inst = inst; p->ua = inst->ua; return p; +} +void chat_join_port_retain(struct chat_join_port* p) { + if (!p) return; + pthread_mutex_lock(&p->lock); p->refs++; pthread_mutex_unlock(&p->lock); +} +void chat_join_port_release(struct chat_join_port* p) { + if (!p) return; + pthread_mutex_lock(&p->lock); unsigned refs = --p->refs; pthread_mutex_unlock(&p->lock); + if (!refs) { pthread_mutex_destroy(&p->lock); u_free(p); } +} +static void execute(void* arg) { + struct join_command* command = arg; + struct chat_join_port* p = command->port; + pthread_mutex_lock(&p->lock); + struct join_command** link = &p->commands; + while (*link && *link != command) link = &(*link)->next; + if (*link) *link = command->next; + struct UTUN_INSTANCE* inst = p->inst; + if (!command->completed && inst && !p->active_request) p->active_request = command->request; + pthread_mutex_unlock(&p->lock); + if (!command->completed && inst) { + int result = command->offer ? chat_sync_offer_link(inst, command->request, command->offer, command->link, command->len) + : chat_sync_join_link(inst, command->request, command->link, command->len); + if (result) { + chat_join_port_complete(p, command->request); + chat_sync_join_result(inst, command->request, command->channel, command->peer, result); + } + } + chat_join_port_release(p); /* память command освободит uasync */ +} +static int submit(struct chat_join_port* p, uint64_t request, const char* link, size_t len, uint64_t offer) { + if (!p || !request || !link || !len || len >= INVITE_LINK_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join command invalid or port unavailable"); return CHAT_CONNECT_INVALID; + } + struct InviteData invite; + if (invite_link_decode(link, len, &invite, NULL, 0)) return CHAT_CONNECT_INVALID; + struct join_command* command = u_calloc(1, sizeof(*command) + len); + if (!command) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "join command allocation failed"); return CHAT_CONNECT_ERROR; } + command->post.callback = execute; command->post.arg = command; command->port = p; + command->offer = offer; command->request = request; command->channel = offer ? offer : invite.channelId; command->peer = invite.nodeId; + command->len = len; memcpy(command->link, link, len); + pthread_mutex_lock(&p->lock); + if (!p->inst) { + pthread_mutex_unlock(&p->lock); u_free(command); + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join command rejected: port closed"); return CHAT_CONNECT_CANCELLED; + } + if (p->active_request == request) { + pthread_mutex_unlock(&p->lock); u_free(command); + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "join request already active"); return CHAT_CONNECT_BUSY; + } + for (struct join_command* c = p->commands; c; c = c->next) if (c->request == request) { + pthread_mutex_unlock(&p->lock); u_free(command); + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "duplicate pending join request"); return CHAT_CONNECT_BUSY; + } + p->refs++; command->next = p->commands; p->commands = command; + uasync_post_reserved(p->ua, &command->post); + pthread_mutex_unlock(&p->lock); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "join command queued request=%llu", (unsigned long long)request); + return 0; +} +void chat_join_port_close(struct chat_join_port* p) { + if (!p) return; + pthread_mutex_lock(&p->lock); + struct UTUN_INSTANCE* inst = p->inst; + struct UASYNC* ua = p->ua; + struct join_command* commands = p->commands; + p->commands = NULL; p->inst = NULL; p->ua = NULL; + pthread_mutex_unlock(&p->lock); + while (commands) { + struct join_command* c = commands; commands = c->next; + c->completed = 1; + uint64_t request = c->request, channel = c->channel, peer = c->peer; + /* Уже извлечённый batch исполнит пустой callback и снимет свою ссылку. */ + if (uasync_cancel_post(ua, &c->post) == ERR_OK) chat_join_port_release(p); + chat_sync_join_result(inst, request, channel, peer, CHAT_CONNECT_CANCELLED); + } + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "join port closed"); +} + +void chat_join_port_complete(struct chat_join_port* p, uint64_t request) { + if (!p) return; + pthread_mutex_lock(&p->lock); + if (p->active_request == request) p->active_request = 0; + pthread_mutex_unlock(&p->lock); +} + +int chat_join_port_submit(struct chat_join_port* p, uint64_t request, const char* link, size_t len) { + return submit(p, request, link, len, 0); +} +int chat_join_port_offer(struct chat_join_port* p, uint64_t request, uint64_t channel, const char* link, size_t len) { + if (!channel) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "offer missing channel"); return CHAT_CONNECT_INVALID; } + return submit(p, request, link, len, channel); +} diff --git a/src/chat/chat_join_port.h b/src/chat/chat_join_port.h new file mode 100644 index 00000000..1f9a5b2c --- /dev/null +++ b/src/chat/chat_join_port.h @@ -0,0 +1,32 @@ +/* Потокобезопасный вход команд JOIN для GUI/JNI. Контекст удерживается фронтендом + * от bind до unbind; close в uasync отменяет команды и запрещает дальнейшие submit. + * После close порт живёт до release последних ссылок, не содержит живого instance. */ +#ifndef CHAT_JOIN_PORT_H +#define CHAT_JOIN_PORT_H +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +struct UTUN_INSTANCE; +struct chat_join_port; +/* Создать порт в uasync; одна ссылка принадлежит chat_sync, NULL при ошибке. */ +struct chat_join_port* chat_join_port_create(struct UTUN_INSTANCE* inst); +/* Получить отдельную ссылку на порт текущего чата; только uasync, NULL до init. */ +struct chat_join_port* chat_sync_join_port(struct UTUN_INSTANCE* inst); +/* Управление отдельной ссылкой. retain требует уже удерживаемой ссылки. */ +void chat_join_port_retain(struct chat_join_port* port); +void chat_join_port_release(struct chat_join_port* port); +/* uasync: снять корреляцию завершённой активной команды до уведомления. */ +void chat_join_port_complete(struct chat_join_port* port, uint64_t request); +/* uasync: закрыть приём и завершить ожидающие команды CANCELLED; повтор допустим. */ +void chat_join_port_close(struct chat_join_port* port); +/* Любой поток: скопировать ссылку и принять команду. 0 — будет один результат с request; + * <0 — команда не принята, результата не будет. request != 0; link заимствован до возврата. */ +int chat_join_port_submit(struct chat_join_port* port, uint64_t request, const char* link, size_t len); +/* Те же гарантии для приглашения адресата ссылки в собственный channel. */ +int chat_join_port_offer(struct chat_join_port* port, uint64_t request, uint64_t channel, const char* link, size_t len); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index dc37305f..bc5e9259 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -6,6 +6,8 @@ #include "chat_event.h" #include "chat_admin.h" #include "chat_join.h" +#include "chat_join_port.h" +#include "invite_link.h" #include "group_avatar.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" @@ -62,11 +64,13 @@ struct pending_sync { struct join_pending { uint64_t node_id; /* joiner node_id */ char ch_id[64]; - struct ETCP_CONN* conn; /* прямое соединение к джойнеру */ + uint64_t attempt; /* идентификатор конкретного JOIN_REQUEST */ + uint64_t expires; /* deadline ожидания записи, timebase */ struct join_pending* next; }; struct chat_sync { + struct chat_join_port* port; struct UTUN_INSTANCE* inst; struct channel_cache* channels; int channel_count; @@ -79,13 +83,13 @@ struct chat_sync { uint8_t sync_draining; /* новые задания во время flush обслужит следующий проход */ void* invite_send_timer; /* timer waiting for joiner to respond to CHANNEL_INVITE */ uint8_t initialized; - uint64_t pending_invite_ch_id; - uint64_t pending_invite_node_id; - uint8_t pending_invite_is_inviter; /* 1 = inviter sends CHANNEL_INVITE on conn_up, 0 = joiner sends JOIN_INFO_REQ */ - char pending_password[128]; /* пароль для JOIN_INFO_REQ */ - uint64_t pending_join_key; /* join-ключ из invite-ссылки (джойнер) */ - struct NODE_CONN_DIRECT* join_handle; /* транспорт протокола добавления, не членство */ - void* join_transport_timer; + struct { + uint64_t channel, peer, key, request, attempt; + uint8_t inviter, phase; /* 0=idle, 1=connect, 2=info, 3=ready, 4=offer, 5=prepare */ + char password[INVITE_PASS_MAX]; + struct NODE_CONN_DIRECT* handle; + void* transport_timer; + } join; struct join_pending* join_pending; /* connection-узел: ожидающие ready джойнеры */ /* Pending incoming invites (для ask/deny) */ uint64_t pending_inv_in_ch_id[4]; @@ -101,19 +105,20 @@ struct chat_sync { #define CS_SYNC_INTERVAL_MS 1000 static void cs_info_req_timeout_cb(void* arg); +static void cs_join_finish(struct chat_sync* cs, int result); static void cs_join_connect_timeout(void* arg) { struct chat_sync* cs = arg; - cs->join_transport_timer = NULL; + cs->join.transport_timer = NULL; cs_info_req_timeout_cb(cs); } static void cs_join_release_transport(struct chat_sync* cs) { - if (cs->join_transport_timer) { - uasync_cancel_timeout(cs->inst->ua, cs->join_transport_timer); cs->join_transport_timer = NULL; + if (cs->join.transport_timer) { + uasync_cancel_timeout(cs->inst->ua, cs->join.transport_timer); cs->join.transport_timer = NULL; } - struct NODE_CONN_DIRECT* handle = cs->join_handle; - cs->join_handle = NULL; + struct NODE_CONN_DIRECT* handle = cs->join.handle; + cs->join.handle = NULL; if (handle) { DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join transport release peer=%016llx", (unsigned long long)node_conn_direct_node_id(handle)); @@ -122,22 +127,20 @@ static void cs_join_release_transport(struct chat_sync* cs) { } static int cs_join_hold_transport(struct chat_sync* cs, uint64_t node_id, struct TOPO_NODE* ni) { + if (cs->join.handle) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join already owns transport"); return -1; } + void* timer = uasync_set_timeout(cs->inst->ua, CS_INFO_REQ_TIMEOUT_MS * 10, cs, + cs_join_connect_timeout, "join_transport_timeout"); + if (!timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "cannot reserve join deadline"); return -1; } struct NODE_CONN_DIRECT* handle = NULL; int rc = ni ? node_conn_direct_open_node(cs->inst, node_id, NULL, NULL, &handle, ni, NULL) : node_conn_direct_open(cs->inst, node_id, NULL, NULL, &handle, NULL); if (rc == NCD_ERR) { + uasync_cancel_timeout(cs->inst->ua, timer); DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "cannot acquire join transport peer=%016llx", (unsigned long long)node_id); return -1; } - cs_join_release_transport(cs); - cs->join_handle = handle; - cs->join_transport_timer = uasync_set_timeout(cs->inst->ua, CS_INFO_REQ_TIMEOUT_MS * 10, - cs, cs_join_connect_timeout, "join_transport_timeout"); - if (!cs->join_transport_timer) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "cannot arm join transport timeout"); - cs_join_release_transport(cs); return -1; - } - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "join owns transport peer=%016llx", (unsigned long long)node_id); + cs->join.handle = handle; cs->join.transport_timer = timer; cs->join.phase = 1; + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join transport acquired peer=%016llx", (unsigned long long)node_id); return 0; } @@ -166,6 +169,43 @@ static void cs_cancel_proto_timers(struct chat_sync* cs) { if (cs->invite_send_timer) { uasync_cancel_timeout(cs->inst->ua, cs->invite_send_timer); cs->invite_send_timer = NULL; } } +void chat_sync_join_result(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, uint64_t peer, int result) { + uint8_t data[28]; int32_t code = result; + memcpy(data, &peer, 8); memcpy(data + 8, &code, 4); memcpy(data + 12, &channel, 8); memcpy(data + 20, &request, 8); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "join result request=%llu channel=%llu peer=%016llx result=%d", + (unsigned long long)request, (unsigned long long)channel, (unsigned long long)peer, result); + chat_event_post(inst, CHAT_EVT_CONNECT_RESULT, data, sizeof(data)); +} + +static void cs_join_end(struct chat_sync* cs, int result, int notify) { + if (!cs->join.channel) return; + struct UTUN_INSTANCE* inst = cs->inst; + uint64_t channel = cs->join.channel, peer = cs->join.peer, request = cs->join.request; + struct NODE_CONN_DIRECT* handle = cs->join.handle; + cs_cancel_proto_timers(cs); + if (cs->join.transport_timer) uasync_cancel_timeout(inst->ua, cs->join.transport_timer); + memset(&cs->join, 0, sizeof(cs->join)); + chat_join_port_complete(cs->port, request); + if (handle) node_conn_direct_close(handle); + if (notify) chat_sync_join_result(inst, request, channel, peer, result); +} + +static void cs_join_finish(struct chat_sync* cs, int result) { cs_join_end(cs, result, 1); } + +static int cs_join_reserve(struct chat_sync* cs, uint64_t channel, uint64_t peer, uint64_t request, int inviter) { + if (!cs || !cs->initialized || !channel || !peer || peer == cs->inst->node_id) { + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "invalid or stopped local join"); return CHAT_CONNECT_INVALID; + } + if (cs->join.channel) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "local join busy"); return CHAT_CONNECT_BUSY; } + uint64_t attempt; + if (RAND_bytes((uint8_t*)&attempt, sizeof(attempt)) != 1 || !attempt) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join attempt random generation failed"); return CHAT_CONNECT_ERROR; + } + cs->join.channel = channel; cs->join.peer = peer; cs->join.request = request; + cs->join.attempt = attempt; cs->join.inviter = inviter; cs->join.phase = 5; + return 0; +} + /* ── Forward decl ── */ static struct ETCP_CONN* cs_find_conn_for_node(struct UTUN_INSTANCE* inst, uint64_t node_id); @@ -212,6 +252,16 @@ static int cs_send(struct chat_sync* cs, const char* ch_id, uint64_t dst, return rc; } +static int cs_send_attempt(struct chat_sync* cs, const char* channel, uint64_t peer, + const uint8_t* payload, size_t len, uint64_t attempt) { + if (!attempt || !len) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "missing join attempt"); return -1; } + uint8_t* data = u_malloc(len + 8); + if (!data) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join wire allocation failed"); return -1; } + data[0] = payload[0]; uint64_t wire = htobe64(attempt); memcpy(data + 1, &wire, 8); + memcpy(data + 9, payload + 1, len - 1); + int rc = cs_send(cs, channel, peer, data, len + 8); u_free(data); return rc; +} + /* ── Channel cache ── */ static struct channel_cache* cs_find(struct chat_sync* cs, const char* ch_id) { @@ -226,11 +276,11 @@ static struct channel_cache* cs_find(struct chat_sync* cs, const char* ch_id) { static void cs_propagate(struct chat_sync* cs, const char* ch_id, uint64_t exclude_id, const uint8_t* payload, size_t len); static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, - const char* ch_id, const uint8_t* pl, size_t len); + const char* ch_id, const uint8_t* pl, size_t len, uint64_t attempt); static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, const char* ch_id, const uint8_t* pl, size_t len); static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, - const char* ch_id, const uint8_t* pl, size_t len); + const char* ch_id, const uint8_t* pl, size_t len, uint64_t attempt); static void cs_handle_join_ready(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, @@ -244,6 +294,7 @@ static void cs_on_member_applied(const char* ch_id, uint64_t node_id, const uint8_t* x25519, uint64_t signed_by, const uint8_t* signature, void* arg); static void cs_pending_free_all(struct chat_sync* cs); +static void cs_pending_prune(struct chat_sync* cs); /* ── Recv dispatcher ── */ static void chat_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { @@ -283,25 +334,29 @@ static void chat_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: RECV %s from=%016llx ch=%s len=%zu", CS_ID, cs_msg_name(type), (unsigned long long)peer, ch_id, plen); + uint64_t attempt = 0; + if (type == CS_MSG_JOIN_INFO_REQ || type == CS_MSG_JOIN_INFO_RESP || type == CS_MSG_JOIN_REQUEST || + type == CS_MSG_JOIN_READY || type == CS_MSG_ERROR) { + if (plen < 8) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "join missing attempt type=%u", type); goto received; } + memcpy(&attempt, pl, 8); attempt = be64toh(attempt); pl += 8; plen -= 8; + if (!attempt) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "join zero attempt type=%u", type); goto received; } + } if ((type == CS_MSG_JOIN_INFO_RESP || type == CS_MSG_JOIN_READY || type == CS_MSG_ERROR) && - (cs->pending_invite_node_id != peer || cs->pending_invite_ch_id != group_id)) { + (cs->join.peer != peer || cs->join.channel != group_id || cs->join.attempt != attempt || + (type == CS_MSG_JOIN_INFO_RESP && cs->join.phase != 2) || (type == CS_MSG_JOIN_READY && cs->join.phase != 3))) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "ignore stale join response peer=%016llx group=%016llx type=%u", (unsigned long long)peer, (unsigned long long)group_id, type); u_free(entry->dgram); queue_entry_free(entry); return; } switch (type) { - case CS_MSG_JOIN_INFO_REQ: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_INFO_REQ", CS_ID); cs_handle_join_info_req(cs, peer, ch_id, pl, plen); break; + case CS_MSG_JOIN_INFO_REQ: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_INFO_REQ", CS_ID); cs_handle_join_info_req(cs, peer, ch_id, pl, plen, attempt); break; case CS_MSG_JOIN_INFO_RESP: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_INFO_RESP", CS_ID); cs_handle_join_info_resp(cs, peer, ch_id, pl, plen); break; - case CS_MSG_JOIN_REQUEST: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_REQUEST", CS_ID); cs_handle_join_request(cs, peer, ch_id, pl, plen); break; + case CS_MSG_JOIN_REQUEST: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_REQUEST", CS_ID); cs_handle_join_request(cs, peer, ch_id, pl, plen, attempt); break; case CS_MSG_JOIN_READY: DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: → handle JOIN_READY", CS_ID); cs_handle_join_ready(cs, peer, ch_id, pl, plen); break; case CS_MSG_ERROR: { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: RECV ERROR from=%016llx ch=%s", CS_ID, (unsigned long long)peer, ch_id); - cs_cancel_proto_timers(cs); - uint8_t err[20]; memcpy(err, &peer, 8); int r = -1; memcpy(err + 8, &r, 4); - memcpy(err + 12, &cs->pending_invite_ch_id, 8); chat_event_post(cs->inst, CHAT_EVT_CONNECT_RESULT, err, 20); - cs->pending_invite_node_id = 0; cs->pending_invite_ch_id = 0; - cs_join_release_transport(cs); + cs_join_finish(cs, CHAT_CONNECT_ERROR); break; } case CS_MSG_CHANNEL_INVITE: cs_handle_channel_invite(cs, peer, ch_id, pl, plen); break; @@ -311,60 +366,21 @@ static void chat_sync_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { break; default: DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: UNKNOWN msg type=%02x from=%016llx", CS_ID, type, (unsigned long long)peer); break; } +received: u_free(entry->dgram); queue_entry_free(entry); } /* ── Connection callbacks ── */ static void cs_info_req_timeout_cb(void* arg) { - struct chat_sync* cs = (struct chat_sync*)arg; + struct chat_sync* cs = arg; if (!cs || !cs->initialized) return; - cs->info_req_timer = NULL; - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_INFO_REQ timeout peer=%016llx ch=%llu", - CS_ID, (unsigned long long)cs->pending_invite_node_id, - (unsigned long long)cs->pending_invite_ch_id); - uint8_t err[20]; memcpy(err, &cs->pending_invite_node_id, 8); - int r = 2; memcpy(err + 8, &r, 4); /* timeout */ - memcpy(err + 12, &cs->pending_invite_ch_id, 8); - chat_event_post(cs->inst, CHAT_EVT_CONNECT_RESULT, err, 20); - cs->pending_invite_node_id = 0; - cs->pending_invite_ch_id = 0; - cs_join_release_transport(cs); -} - -static void cs_join_timeout_cb(void* arg) { - struct chat_sync* cs = (struct chat_sync*)arg; - if (!cs || !cs->initialized) return; - cs->join_timer = NULL; - uint64_t peer = cs->pending_invite_node_id; - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_JOIN timeout peer=%016llx ch=%llu", - CS_ID, (unsigned long long)peer, (unsigned long long)cs->pending_invite_ch_id); - uint8_t err[20]; memcpy(err, &peer, 8); - int r = 2; memcpy(err + 8, &r, 4); /* timeout */ - memcpy(err + 12, &cs->pending_invite_ch_id, 8); - chat_event_post(cs->inst, CHAT_EVT_CONNECT_RESULT, err, 20); - cs->pending_invite_node_id = 0; - cs->pending_invite_ch_id = 0; - cs->pending_invite_is_inviter = 0; - cs_join_release_transport(cs); -} - -static void cs_invite_send_timeout_cb(void* arg) { - struct chat_sync* cs = (struct chat_sync*)arg; - if (!cs || !cs->initialized) return; - cs->invite_send_timer = NULL; - uint64_t peer = cs->pending_invite_node_id; - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_INVITE timeout — joiner did not respond peer=%016llx ch=%llu", - CS_ID, (unsigned long long)peer, (unsigned long long)cs->pending_invite_ch_id); - uint8_t err[20]; memcpy(err, &peer, 8); - int r = 2; memcpy(err + 8, &r, 4); /* timeout */ - memcpy(err + 12, &cs->pending_invite_ch_id, 8); - chat_event_post(cs->inst, CHAT_EVT_CONNECT_RESULT, err, 20); - cs->pending_invite_node_id = 0; - cs->pending_invite_ch_id = 0; - cs->pending_invite_is_inviter = 0; - cs_join_release_transport(cs); + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "join timeout phase=%u channel=%llu peer=%016llx", + cs->join.phase, (unsigned long long)cs->join.channel, (unsigned long long)cs->join.peer); + cs_join_finish(cs, CHAT_CONNECT_TIMEOUT); } +static void cs_join_timeout_cb(void* arg) { cs_info_req_timeout_cb(arg); } +static void cs_invite_send_timeout_cb(void* arg) { cs_info_req_timeout_cb(arg); } /* ── helper: find active ETCP_CONN for node ── */ @@ -669,26 +685,37 @@ static void _on_invite_sync_done(uint64_t peer, const char* ns, int result, void /* ── send JOIN_INFO_REQ to start join handshake ── */ -static void cs_start_channel_join(struct chat_sync* cs, uint64_t peer) { - if (!cs || cs->info_req_timer || cs->join_timer) return; - if (cs->join_transport_timer) { - uasync_cancel_timeout(cs->inst->ua, cs->join_transport_timer); cs->join_transport_timer = NULL; +static int cs_start_channel_join(struct chat_sync* cs, uint64_t peer) { + if (!cs || cs->info_req_timer || cs->join_timer) return 0; + char channel[64]; snprintf(channel, sizeof(channel), "%llu", (unsigned long long)cs->join.channel); + size_t len = strlen(cs->join.password); + uint8_t data[140]; data[0] = CS_MSG_JOIN_INFO_REQ; + memcpy(data + 1, &cs->join.key, 8); data[9] = (uint8_t)len; memcpy(data + 10, cs->join.password, len); + 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"); + if (!cs->info_req_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join info deadline allocation failed"); return -1; } + cs->join.phase = 2; + if (cs_send_attempt(cs, channel, peer, data, len + 10, cs->join.attempt)) return -1; + if (cs->join.transport_timer) { uasync_cancel_timeout(cs->inst->ua, cs->join.transport_timer); cs->join.transport_timer = NULL; } + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join info requested channel=%s attempt=%016llx", channel, + (unsigned long long)cs->join.attempt); + return 0; +} + +static int cs_start_channel_offer(struct chat_sync* cs, uint64_t peer) { + char channel[64], name[128]; uint64_t owner; + uint8_t x[32], ed[32], signature[64]; + snprintf(channel, sizeof(channel), "%llu", (unsigned long long)cs->join.channel); + if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, channel, name, sizeof(name), &owner, x, ed, signature)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "offer channel missing ch=%s", channel); return -1; } - char ch_id_str[64]; - snprintf(ch_id_str, sizeof(ch_id_str), "%llu", (unsigned long long)cs->pending_invite_ch_id); - /* payload: [join_key:8][pass_len:1][password] */ - size_t plen = strlen(cs->pending_password); - uint8_t req[140]; - req[0] = CS_MSG_JOIN_INFO_REQ; - memcpy(req + 1, &cs->pending_join_key, 8); - req[9] = (uint8_t)(plen > 127 ? 127 : plen); - if (plen > 0) memcpy(req + 10, cs->pending_password, plen > 127 ? 127 : plen); - cs_send(cs, ch_id_str, peer, req, 10 + (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_MEMBER_SYNC, "%s: sent JOIN_INFO_REQ ch=%llu peer=0x%016llx key=%016llx pass_len=%zu", - CS_ID, (unsigned long long)cs->pending_invite_ch_id, (unsigned long long)peer, - (unsigned long long)cs->pending_join_key, plen); + cs->invite_send_timer = uasync_set_timeout(cs->inst->ua, CS_INVITE_SEND_TIMEOUT_MS * 10, cs, + cs_invite_send_timeout_cb, "cs_invite_send"); + if (!cs->invite_send_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "offer deadline allocation failed"); return -1; } + cs->join.phase = 4; + if (cs_send_channel_invite(cs, cs->inst, channel, peer, name, owner, x, ed, signature)) return -1; + if (cs->join.transport_timer) { uasync_cancel_timeout(cs->inst->ua, cs->join.transport_timer); cs->join.transport_timer = NULL; } + return 0; } /* ── Connection callbacks ── */ @@ -701,28 +728,15 @@ static void cs_on_conn_up(struct ETCP_CONN* conn, int event, void* arg) { DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: conn_up peer=%016llx init=%d links=%d", CS_ID, (unsigned long long)peer, conn->initialized, conn->links_up); if (!conn->initialized) { DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: conn_up SKIP — not initialized peer=%016llx", CS_ID, (unsigned long long)peer); return; } - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: conn_up peer=%016llx pending_invite=%llu links_up=%d", CS_ID, (unsigned long long)peer, (unsigned long long)cs->pending_invite_ch_id, conn->links_up); + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: conn_up peer=%016llx pending_invite=%llu links_up=%d", CS_ID, (unsigned long long)peer, (unsigned long long)cs->join.channel, conn->links_up); cs_schedule_peer_sync(cs, peer, event == ETCP_CBK_EVENT_REINIT ? "reinit" : "transport_up"); - if (cs->pending_invite_ch_id != 0 && cs->pending_invite_node_id == peer) { - if (cs->join_transport_timer) { - uasync_cancel_timeout(cs->inst->ua, cs->join_transport_timer); cs->join_transport_timer = NULL; - } - if (cs->pending_invite_is_inviter) { - /* we are the inviter — send CHANNEL_INVITE to the joiner */ - char ch_id[64]; snprintf(ch_id, sizeof(ch_id), "%llu", - (unsigned long long)cs->pending_invite_ch_id); - char ch_name[128]; uint8_t ch_x2[32], ch_ed2[32], ch_sig[64]; uint64_t ch_owner; - if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, - ch_id, ch_name, (int)sizeof(ch_name), &ch_owner, ch_x2, ch_ed2, ch_sig) == 0) { - cs_send_channel_invite(cs, cs->inst, ch_id, peer, ch_name, ch_owner, ch_x2, ch_ed2, ch_sig); - if (cs->invite_send_timer) { uasync_cancel_timeout(cs->inst->ua, cs->invite_send_timer); } - cs->invite_send_timer = uasync_set_timeout(cs->inst->ua, - CS_INVITE_SEND_TIMEOUT_MS * 10, cs, cs_invite_send_timeout_cb, "cs_invite_send"); - } - } else { - cs_start_channel_join(cs, peer); + if (cs->join.channel != 0 && cs->join.peer == peer && cs->join.phase == 1) { + if (cs->join.transport_timer) { + uasync_cancel_timeout(cs->inst->ua, cs->join.transport_timer); cs->join.transport_timer = NULL; } + int rc = cs->join.inviter ? cs_start_channel_offer(cs, peer) : cs_start_channel_join(cs, peer); + if (rc < 0) cs_join_finish(cs, CHAT_CONNECT_ERROR); cs_on_peer_status_changed(cs, peer, 1); return; } @@ -740,11 +754,13 @@ static void cs_on_conn_down(struct ETCP_CONN* conn, int event, void* arg) { (voi cs_on_peer_status_changed(cs, peer, 0); - if (cs->pending_invite_node_id == peer) { + if (cs->join.peer == peer && cs->join.phase != 5) { cs_cancel_proto_timers(cs); - if (cs->join_transport_timer) uasync_cancel_timeout(cs->inst->ua, cs->join_transport_timer); - cs->join_transport_timer = uasync_set_timeout(cs->inst->ua, CS_INFO_REQ_TIMEOUT_MS * 10, + cs->join.phase = 1; + if (cs->join.transport_timer) uasync_cancel_timeout(cs->inst->ua, cs->join.transport_timer); + cs->join.transport_timer = uasync_set_timeout(cs->inst->ua, CS_INFO_REQ_TIMEOUT_MS * 10, cs, cs_join_connect_timeout, "join_reconnect_timeout"); + if (!cs->join.transport_timer) { cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: conn down while waiting invite resp peer=%016llx, timers cancelled, state kept for retry on reconnect", CS_ID, (unsigned long long)peer); /* invite state survives connection flaps: only timeout or explicit protocol end clears it */ @@ -767,6 +783,7 @@ static void cs_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) { /* ── Periodic refresh from DB ── */ static void cs_refresh_channels(struct chat_sync* cs) { + cs_pending_prune(cs); uint8_t buf[4096]; size_t buf_len; if (chat_core_list_channels(cs->inst, buf, sizeof(buf), &buf_len) != 0) return; if (buf_len < 2) return; @@ -839,17 +856,19 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { cs->inst = inst; cs->initialized = 1; inst->chat_sync = cs; + cs->port = chat_join_port_create(inst); + if (!cs->port) { chat_join_port_close(cs->port); chat_join_port_release(cs->port); inst->chat_sync = NULL; u_free(cs); return -1; } if (member_sync_init(inst) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: member synchronization initialization failed", CS_ID); - inst->chat_sync = NULL; u_free(cs); return -1; + chat_join_port_close(cs->port); chat_join_port_release(cs->port); inst->chat_sync = NULL; u_free(cs); return -1; } if (chat_feedback_init(inst) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: feedback initialization failed", CS_ID); - member_sync_destroy(inst); inst->chat_sync = NULL; u_free(cs); return -1; + member_sync_destroy(inst); chat_join_port_close(cs->port); chat_join_port_release(cs->port); inst->chat_sync = NULL; u_free(cs); return -1; } if (group_avatar_init(inst) < 0) { - member_sync_destroy(inst); inst->chat_sync = NULL; u_free(cs); return -1; + member_sync_destroy(inst); chat_join_port_close(cs->port); chat_join_port_release(cs->port); inst->chat_sync = NULL; u_free(cs); return -1; } etcp_bind(inst, ETCP_ID_CHAT_SYNC, chat_sync_recv_cb); @@ -877,10 +896,23 @@ int chat_sync_init(struct UTUN_INSTANCE* inst) { return 0; } +void chat_sync_quiesce(struct UTUN_INSTANCE* inst) { + struct chat_sync* cs = cs_of(inst); + if (!cs) return; + cs->initialized = 0; chat_join_port_close(cs->port); cs_join_finish(cs, CHAT_CONNECT_CANCELLED); +} +void chat_sync_cancel_join(struct UTUN_INSTANCE* inst, uint64_t request) { + struct chat_sync* cs = cs_of(inst); + if (cs && cs->join.channel && cs->join.request == request) cs_join_finish(cs, CHAT_CONNECT_CANCELLED); +} + void chat_sync_destroy(struct UTUN_INSTANCE* inst) { struct chat_sync* cs = cs_of(inst); if (!cs || !inst) return; DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: destroy", CS_ID); + cs->initialized = 0; + chat_join_port_close(cs->port); + cs_join_finish(cs, CHAT_CONNECT_CANCELLED); cs_cancel_sync(cs, 0, 0, "service_stop"); if (cs->invite_send_timer) { uasync_cancel_timeout(inst->ua, cs->invite_send_timer); cs->invite_send_timer = NULL; } member_sync_remove_apply_cbk(inst, cs_on_member_applied, cs); @@ -904,9 +936,16 @@ void chat_sync_destroy(struct UTUN_INSTANCE* inst) { for (int i = 0; i < cs->channel_count; i++) if (cs->channels[i].peer_ids) u_free(cs->channels[i].peer_ids); if (cs->channels) u_free(cs->channels); + chat_join_port_release(cs->port); u_free(cs); } +struct chat_join_port* chat_sync_join_port(struct UTUN_INSTANCE* inst) { + struct chat_sync* cs = cs_of(inst); + if (!cs || !cs->initialized) return NULL; + chat_join_port_retain(cs->port); return cs->port; +} + void chat_sync_retry_channels_on_socket_change(struct UTUN_INSTANCE* inst) { struct chat_sync* cs = cs_of(inst); if (!cs || !cs->initialized || !inst->topo_groups) return; @@ -926,11 +965,7 @@ void chat_sync_on_channel_deleted(struct UTUN_INSTANCE* inst, const char* ch_id) struct chat_sync* cs = cs_of(inst); if (!cs || !ch_id || !ch_id[0]) return; cs_cancel_sync(cs, 0, strtoull(ch_id, NULL, 10), "channel_deleted"); - if (cs->pending_invite_ch_id == strtoull(ch_id, NULL, 10)) { - cs_cancel_proto_timers(cs); - cs->pending_invite_ch_id = 0; cs->pending_invite_node_id = 0; - cs_join_release_transport(cs); - } + if (cs->join.channel == strtoull(ch_id, NULL, 10)) cs_join_finish(cs, CHAT_CONNECT_CANCELLED); for (int i = 0; i < cs->channel_count; i++) { if (strcmp(cs->channels[i].channel_id, ch_id) != 0) continue; if (cs->channels[i].peer_ids) u_free(cs->channels[i].peer_ids); @@ -942,225 +977,85 @@ void chat_sync_on_channel_deleted(struct UTUN_INSTANCE* inst, const char* ch_id) } } -struct chat_invite { - uint64_t channel_id; - uint64_t node_id; - uint8_t pubkey[32]; - uint8_t* addrs_data; - int addrs_data_len; - int addr_count; - uint64_t join_key; - char password[128]; -}; - -struct cm_invite_wrap { struct UTUN_INSTANCE* inst; struct chat_invite inv; }; - -/* Парсит reality-префикс addrs_data: has(1) + [short_id(8) pubkey(32) ver(3) snlen(1) sn(N)]. - Сдвигает *ap за префикс. Возвращает 1, если reality присутствует (и params заполнены). */ -static int invite_reality_parse(const uint8_t** ap, const uint8_t* end, - uint8_t short_id[8], uint8_t pubkey[32], uint8_t ver[3], - char* sname, size_t sname_sz) { - const uint8_t* p = *ap; - if (p + 1 > end) return 0; - if (!*p++) { *ap = p; return 0; } - if (p + 8 + 32 + 3 + 1 > end) { *ap = end; return 0; } - memcpy(short_id, p, 8); p += 8; - memcpy(pubkey, p, 32); p += 32; - memcpy(ver, p, 3); p += 3; - uint8_t snlen = *p++; - if (snlen > (uint8_t)(sname_sz - 1)) snlen = (uint8_t)(sname_sz - 1); - if (p + snlen > end) { *ap = end; return 0; } - memcpy(sname, p, snlen); sname[snlen] = '\0'; p += snlen; - *ap = p; - return 1; +static void cs_node_clear(struct TOPO_NODE* node) { + while (node->v4_addrs) { struct TOPO_ADDR4* next = node->v4_addrs->next; u_free(node->v4_addrs); node->v4_addrs = next; } + while (node->v6_addrs) { struct TOPO_ADDR6* next = node->v6_addrs->next; u_free(node->v6_addrs); node->v6_addrs = next; } + while (node->reality_socks) { + struct TOPO_REALITY_SOCK* next = node->reality_socks->next; u_free(node->reality_socks); node->reality_socks = next; + } } -/* Добавляет reality-сокет узла с node-global параметрами. */ -static void invite_reality_sock_append(struct TOPO_NODE* ni, uint8_t socket_id, - const uint8_t short_id[8], const uint8_t pubkey[32], - const uint8_t ver[3], const char* sname) { - struct TOPO_REALITY_SOCK* r = u_calloc(1, sizeof(*r)); - if (!r) return; - r->socket_id = socket_id; - memcpy(r->short_id, short_id, 8); - memcpy(r->server_pubkey, pubkey, 32); - memcpy(r->version, ver, 3); - snprintf(r->server_name, sizeof(r->server_name), "%s", sname); - r->next = ni->reality_socks; ni->reality_socks = r; -} - -static void cm_invite_trampoline(void* arg) { - struct cm_invite_wrap* w = (struct cm_invite_wrap*)arg; - struct chat_invite* inv = &w->inv; - struct UTUN_INSTANCE* inst = w->inst; +static int cs_connect_invite(struct UTUN_INSTANCE* inst, uint64_t request, const struct InviteData* data, uint64_t offer_channel) { struct chat_sync* cs = cs_of(inst); - uint64_t node_id = inv->node_id; - uint64_t channel_id = inv->channel_id; - - if (!cs) { u_free(inv->addrs_data); u_free(w); return; } - - /* Параметры invite храним в cs->pending_* здесь, в uasync-потоке (не на вызывающем): - * их читает cs_start_channel_join/cs_on_conn_up из uasync-потока. */ - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = node_id; - cs->pending_join_key = inv->join_key; - snprintf(cs->pending_password, sizeof(cs->pending_password), "%s", inv->password); - - /* build TOPO_NODE from invite data */ - struct TOPO_NODE* ni = u_calloc(1, sizeof(*ni)); - if (!ni) { u_free(inv->addrs_data); u_free(w); return; } - ni->node_id = node_id; memcpy(ni->public_key, inv->pubkey, 32); - const uint8_t* ap = inv->addrs_data; - const uint8_t* ap_end = inv->addrs_data + inv->addrs_data_len; - uint8_t r_short_id[8], r_pubkey[32], r_ver[3]; char r_sname[TOPO_REALITY_SNAME_MAX]; - int has_reality = invite_reality_parse(&ap, ap_end, r_short_id, r_pubkey, r_ver, r_sname, sizeof(r_sname)); - for (int i = 0; i < inv->addr_count && ap + 3 <= ap_end; i++) { - uint8_t family = *ap++; uint8_t sock_id = *ap++; uint8_t proto = *ap++; - if (family == 4 && ap + 6 <= ap_end) { - struct TOPO_ADDR4* a4 = u_calloc(1, sizeof(*a4)); - if (!a4) break; - memcpy(a4->addr, ap, 4); ap += 4; - a4->port = ((uint16_t)ap[0] << 8) | ap[1]; ap += 2; - a4->type = TOPO_ADDR_NAT; a4->protocol = proto; a4->socket_id = sock_id; - a4->next = ni->v4_addrs; ni->v4_addrs = a4; - if (has_reality && (proto & 0x04)) invite_reality_sock_append(ni, sock_id, r_short_id, r_pubkey, r_ver, r_sname); - } else if (family == 6 && ap + 18 <= ap_end) { - struct TOPO_ADDR6* a6 = u_calloc(1, sizeof(*a6)); - if (!a6) break; - memcpy(a6->addr, ap, 16); ap += 16; - a6->port = ((uint16_t)ap[0] << 8) | ap[1]; ap += 2; - a6->type = TOPO_ADDR_NAT; a6->protocol = proto; a6->socket_id = sock_id; - a6->next = ni->v6_addrs; ni->v6_addrs = a6; - if (has_reality && (proto & 0x04)) invite_reality_sock_append(ni, sock_id, r_short_id, r_pubkey, r_ver, r_sname); - } else { break; } + const struct InviteData invite = *data; + int rc = cs_join_reserve(cs, offer_channel ? offer_channel : invite.channelId, invite.nodeId, request, offer_channel != 0); + if (rc) return rc; + struct TOPO_NODE node = { .node_id = invite.nodeId }; + memcpy(node.public_key, invite.pubkey, 32); + for (unsigned i = 0; i < invite.addrCount; i++) { + const struct InviteAddr* source = &invite.addrs[i]; + if (source->family == 4) { + struct TOPO_ADDR4* a = u_calloc(1, sizeof(*a)); + if (!a) goto failure; + memcpy(a->addr, source->address, 4); a->port = source->port; + a->socket_id = source->socketId; a->protocol = source->proto; a->type = TOPO_ADDR_NAT; + a->next = node.v4_addrs; node.v4_addrs = a; + } else { + struct TOPO_ADDR6* a = u_calloc(1, sizeof(*a)); + if (!a) goto failure; + memcpy(a->addr, source->address, 16); a->port = source->port; + a->socket_id = source->socketId; a->protocol = source->proto; a->type = TOPO_ADDR_NAT; + a->next = node.v6_addrs; node.v6_addrs = a; + } + if (source->proto & INVITE_PROTO_REALITY) { + struct TOPO_REALITY_SOCK* r = u_calloc(1, sizeof(*r)); + if (!r) goto failure; + r->socket_id = source->socketId; memcpy(r->short_id, invite.reality.short_id, 8); + memcpy(r->server_pubkey, invite.reality.server_pubkey, 32); memcpy(r->version, invite.reality.version, 3); + snprintf(r->server_name, sizeof(r->server_name), "%s", invite.reality.server_name); + r->next = node.reality_socks; node.reality_socks = r; + } } - - { char buf[512]; int off = 0; int n4 = 0, n6 = 0; - for (const struct TOPO_ADDR4* a4 = ni->v4_addrs; a4; a4 = a4->next) { - n4++; - off += snprintf(buf + off, sizeof(buf) - (size_t)off, "%s%d.%d.%d.%d:%d(p=%d)", - off ? "," : "", a4->addr[0], a4->addr[1], a4->addr[2], a4->addr[3], a4->port, a4->protocol); - } - for (const struct TOPO_ADDR6* a6 = ni->v6_addrs; a6; a6 = a6->next) { - n6++; - off += snprintf(buf + off, sizeof(buf) - (size_t)off, "%s%s:%d(p=%d)", - off ? "," : "", ip_to_str(a6->addr, AF_INET6).str, a6->port, a6->protocol); - } - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "channel invite: parsed node=0x%016llx addrs v4=%d v6=%d [%s]", - (unsigned long long)node_id, n4, n6, (n4 || n6) ? buf : "(none)"); - } - - if (!ni->v4_addrs && !ni->v6_addrs) { - DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "invite: no valid addrs for node=0x%016llx", (unsigned long long)node_id); - while (ni->v4_addrs) { struct TOPO_ADDR4* n = ni->v4_addrs->next; u_free(ni->v4_addrs); ni->v4_addrs = n; } - while (ni->v6_addrs) { struct TOPO_ADDR6* n = ni->v6_addrs->next; u_free(ni->v6_addrs); ni->v6_addrs = n; } - u_free(ni); u_free(inv->addrs_data); u_free(w); return; - } - - /* если линк к узлу уже есть и он up — не открываем новое подключение, - сразу запускаем join-хендшейк (JOIN_INFO_REQ → … → JOIN_READY) */ - struct ETCP_CONN* conn = instance_find_conn(inst, node_id); + cs->join.key = invite.join_key; memcpy(cs->join.password, invite.password, sizeof(cs->join.password)); + rc = cs_join_hold_transport(cs, invite.nodeId, &node); + if (rc < 0) goto failure; + struct ETCP_CONN* conn = node_conn_direct_get_conn(cs->join.handle); if (conn && conn->links_up && conn->initialized) { - cs->pending_invite_node_id = node_id; - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, - "channel invite: link to 0x%016llx already up — skipping connect, join handshake directly", - (unsigned long long)node_id); - if (cs_join_hold_transport(cs, node_id, NULL) == 0) cs_start_channel_join(cs, node_id); - } else { - /* Транспорт принадлежит join-протоколу до его результата, а не будущей BGP-группе. - JOIN_INFO_REQ → … → JOIN_READY запускается по cs_on_conn_up. */ - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "channel invite: connecting to 0x%016llx via %d addresses", - (unsigned long long)node_id, inv->addr_count); - int r = cs_join_hold_transport(cs, node_id, ni); - if (r < 0) - DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "channel invite: FAILED — topo_group_invite_to_channel error=%d", r); + int started = offer_channel ? cs_start_channel_offer(cs, invite.nodeId) : cs_start_channel_join(cs, invite.nodeId); + if (started < 0) goto failure; } - - /* free caller-owned ni */ - while (ni->v4_addrs) { struct TOPO_ADDR4* n = ni->v4_addrs->next; u_free(ni->v4_addrs); ni->v4_addrs = n; } - while (ni->v6_addrs) { struct TOPO_ADDR6* n = ni->v6_addrs->next; u_free(ni->v6_addrs); ni->v6_addrs = n; } - u_free(ni); - u_free(inv->addrs_data); - u_free(w); + cs_node_clear(&node); + return 0; +failure: + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join preparation failed request=%llu", (unsigned long long)request); + cs_node_clear(&node); + cs_join_end(cs, CHAT_CONNECT_ERROR, 0); + return CHAT_CONNECT_ERROR; } -void chat_sync_connect_from_invite(struct UTUN_INSTANCE* inst, 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, - const char* password, uint64_t join_key) { - struct chat_sync* cs = cs_of(inst); - if (!cs || !cs->inst || !cs->inst->ua) { - int r = -7; -#ifdef __ANDROID__ - __android_log_print(ANDROID_LOG_ERROR, "utun-chat", "invite ch=%llu node=%016llx REJECTED cs=%p inst=%p ua=%p", - (long long)channel_id, (long long)node_id, (void*)cs, - cs ? (void*)cs->inst : NULL, - (cs && cs->inst) ? (void*)cs->inst->ua : NULL); -#endif - uint8_t err[12]; memcpy(err, &node_id, 8); memcpy(err + 8, &r, 4); - chat_event_post(inst, CHAT_EVT_CONNECT_RESULT, err, 12); - return; - } - - struct chat_invite* inv = u_calloc(1, sizeof(struct chat_invite)); - if (!inv) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: connect_from_invite calloc failed", CS_ID); return; } - - inv->channel_id = channel_id; - inv->node_id = node_id; - memcpy(inv->pubkey, pubkey_bin, 32); - - size_t addrs_sz = addrs_data_len > 0 ? (size_t)addrs_data_len : (size_t)addr_count * 9; - inv->addrs_data = u_malloc(addrs_sz); - if (!inv->addrs_data) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: connect_from_invite malloc(%zu) failed", CS_ID, addrs_sz); u_free(inv); return; } - memcpy(inv->addrs_data, addrs_data, addrs_sz); - inv->addrs_data_len = (int)addrs_sz; - inv->addr_count = addr_count; - inv->join_key = join_key; - if (password && password[0]) snprintf(inv->password, sizeof(inv->password), "%s", password); - else inv->password[0] = '\0'; -#ifdef __ANDROID__ - __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__ - uint64_t pubkey_prefix; memcpy(&pubkey_prefix, pubkey_bin, sizeof(pubkey_prefix)); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite start ch=%llu node=0x%016llx pubkey=%016llx... addrs=%d pass=%s", - CS_ID, (unsigned long long)channel_id, (unsigned long long)node_id, (unsigned long long)pubkey_prefix, addr_count, password && password[0] ? "yes" : "no"); -#endif - - struct cm_invite_wrap* w = u_malloc(sizeof(struct cm_invite_wrap)); - w->inst = inst; - w->inv = *inv; u_free(inv); - uasync_post(cs->inst->ua, - (void(*)(void*))cm_invite_trampoline, w); +int chat_sync_join_link(struct UTUN_INSTANCE* inst, uint64_t request, const char* link, size_t len) { + struct InviteData invite; + if (invite_link_decode(link, len, &invite, NULL, 0)) return CHAT_CONNECT_INVALID; + return cs_connect_invite(inst, request, &invite, 0); +} +int chat_sync_offer_link(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, const char* link, size_t len) { + struct InviteData invite; + if (!channel || invite_link_decode(link, len, &invite, NULL, 0)) return CHAT_CONNECT_INVALID; + return cs_connect_invite(inst, request, &invite, channel); } /* ─── chat_sync_join_channel: join channel via already-connected peer ─── */ -void chat_sync_join_channel(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id) { +static void cs_join_existing(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer, uint64_t key) { struct chat_sync* cs = cs_of(inst); - if (!cs) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel: cs=NULL, chat_sync not initialized", CS_ID); return; } - if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel: inst=NULL", CS_ID); return; } - if (!ch_id || !ch_id[0]) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel: ch_id NULL/empty", CS_ID); return; } - uint64_t channel_id = strtoull(ch_id, NULL, 10); - if (channel_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel invalid ch_id='%s'", CS_ID, ch_id); return; } - - struct ETCP_CONN* conn = instance_find_conn(inst, target_node_id); - if (!conn || !conn->links_up || !conn->initialized) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel no active conn to 0x%016llx for ch=%s", - CS_ID, (unsigned long long)target_node_id, ch_id); - return; - } - - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: join_channel ch=%s peer=0x%016llx", - CS_ID, ch_id, (unsigned long long)target_node_id); - - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - if (cs_join_hold_transport(cs, target_node_id, NULL) < 0) return; - cs_start_channel_join(cs, target_node_id); - cs_on_peer_status_changed(cs, target_node_id, 1); + uint64_t group = channel ? strtoull(channel, NULL, 10) : 0; + if (cs_join_reserve(cs, group, peer, 0, 0)) return; + cs->join.key = key; + if (cs_join_hold_transport(cs, peer, NULL) < 0 || cs_start_channel_join(cs, peer) < 0) + cs_join_finish(cs, CHAT_CONNECT_ERROR); +} +void chat_sync_join_channel(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer) { + cs_join_existing(inst, channel, peer, 0); } /* ─── Ed25519 sign / verify helpers ─── */ @@ -1225,20 +1120,20 @@ static int _get_node_name(sqlite3* db, uint64_t node_id, char* out, size_t sz) { /* ─── JOIN_INFO_REQ (0x10): joiner → connection-узел ─── */ static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, - const char* ch_id, const uint8_t* pl, size_t len) { + const char* ch_id, const uint8_t* pl, size_t len, uint64_t attempt) { /* payload: [join_key:8][pass_len:1][password] */ if (len < 9) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ too short len=%zu peer=%016llx", CS_ID, len, (unsigned long long)peer); return; } uint64_t join_key; memcpy(&join_key, pl, 8); uint64_t channel_id = strtoull(ch_id, NULL, 10); - DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ ch=%s from=%016llx key=%016llx len=%zu", - CS_ID, ch_id, (unsigned long long)peer, (unsigned long long)join_key, len); + DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ ch=%s from=%016llx len=%zu", + CS_ID, ch_id, (unsigned long long)peer, len); /* 1. валидация join-ключа (зарегистрирован инвайтером на connection-узле, TTL 10 мин) */ uint64_t inviter = chat_join_lookup_inviter(cs->inst, channel_id, join_key); if (inviter == 0) { - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ invalid/expired join key ch=%s from=%016llx key=%016llx", - CS_ID, ch_id, (unsigned long long)peer, (unsigned long long)join_key); - uint8_t err[1] = { CS_MSG_ERROR }; cs_send(cs, ch_id, peer, err, 1); + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ invalid/expired join key ch=%s from=%016llx", + CS_ID, ch_id, (unsigned long long)peer); + uint8_t err[1] = { CS_MSG_ERROR }; cs_send_attempt(cs, ch_id, peer, err, 1, attempt); return; } @@ -1247,7 +1142,7 @@ static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, ch_id, name, (int)sizeof(name), &owner, ch_x25519, ch_ed, ch_sig) != 0) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_REQ unknown ch=%s from=%016llx", CS_ID, ch_id, (unsigned long long)peer); - uint8_t err[1] = { CS_MSG_ERROR }; cs_send(cs, ch_id, peer, err, 1); + uint8_t err[1] = { CS_MSG_ERROR }; cs_send_attempt(cs, ch_id, peer, err, 1, attempt); return; } @@ -1269,7 +1164,7 @@ static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, if (chat_core_get_my_profile(cs->inst, ch_id, juser, sizeof(juser), my_update_sig, &my_update_ts) < 0) { chat_core_update_my_member(cs->inst); if (chat_core_get_my_profile(cs->inst, ch_id, juser, sizeof(juser), my_update_sig, &my_update_ts) < 0) { - uint8_t err[1] = { CS_MSG_ERROR }; cs_send(cs, ch_id, peer, err, sizeof(err)); return; + uint8_t err[1] = { CS_MSG_ERROR }; cs_send_attempt(cs, ch_id, peer, err, sizeof(err), attempt); return; } } uint64_t my_signed_by = 0; uint8_t my_signature[64] = {0}; @@ -1295,7 +1190,7 @@ static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, buf[boff++] = (uint8_t)(il >> 8); buf[boff++] = (uint8_t)il; memcpy(buf + boff, juser, il); boff += il; memcpy(buf + boff, &my_signed_by, 8); boff += 8; memcpy(buf + boff, my_signature, 64); boff += 64; - cs_send(cs, ch_id, peer, buf, boff); + cs_send_attempt(cs, ch_id, peer, buf, boff, attempt); } /* ─── JOIN_INFO_RESP (0x11): connection-узел → joiner ─── */ @@ -1303,7 +1198,6 @@ static void cs_handle_join_info_req(struct chat_sync* cs, uint64_t peer, static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, const char* ch_id, const uint8_t* pl, size_t len) { DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP ch=%s from=%016llx len=%zu", CS_ID, ch_id, (unsigned long long)peer, len); - if (cs->info_req_timer) { uasync_cancel_timeout(cs->inst->ua, cs->info_req_timer); cs->info_req_timer = NULL; } if (len < 1) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP too short len=%zu", CS_ID, len); return; } const uint8_t* p = pl; uint8_t cnl = *p++; @@ -1351,25 +1245,39 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, return; } - /* save channel */ - topo_node_sqlite_channel_put(cs->inst->topo_sqlite_db, ch_id, name, owner, ch_x25519, NULL, ch_ed, NULL, ch_sig); - chat_core_ensure_channel_ready(cs->inst, ch_id); - - /* добавить connection-узел в мемберы (дерево приглашений из INFO_RESP) */ + uint8_t known_x[32], known_ed[32]; + if (topo_node_sqlite_channel_get(cs->inst->topo_sqlite_db, ch_id, NULL, 0, NULL, known_x, known_ed, NULL) == 0 && + (memcmp(known_x, ch_x25519, 32) || memcmp(known_ed, ch_ed, 32))) { + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "join response conflicts with known channel keys"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } + /* Запись описания и участника атомарна; runtime-группа публикуется после commit. */ + sqlite3* db = cs->inst->topo_sqlite_db; struct ms_member_rec connection_member = { .node_id = c_node_id, .x25519 = c_x25519, .ed25519 = c_ed, .join_sig = c_join_sig, .join_ts = c_join_ts, .update_sig = c_update_sig, .update_ts = c_update_ts, .userinfo = c_userinfo, .signed_by = c_signed_by, .signature = c_signature }; - /* Запись модели и Merkle-индекс меняются в одной транзакции member_sync. */ - int mrc = member_sync_apply_record(cs->inst, ch_id, peer, &connection_member); - if (mrc < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP member rejected ch=%s node=%016llx rc=%d", - CS_ID, ch_id, (unsigned long long)c_node_id, mrc); - return; + struct ETCP_CONN* response_conn = cs_find_conn_for_node(cs->inst, peer); + if (!response_conn || c_node_id != peer || memcmp(c_x25519, response_conn->crypto_ctx.peer_public_key, 32)) { + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "join response connection identity mismatch"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP member indexed ch=%s node=%016llx rc=%d", - CS_ID, ch_id, (unsigned long long)c_node_id, mrc); + if (sqlite3_exec(db, "SAVEPOINT join_channel", NULL, NULL, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join transaction begin: %s", sqlite3_errmsg(db)); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } + int mrc = -1; + if (topo_node_sqlite_channel_put(db, ch_id, name, owner, ch_x25519, NULL, ch_ed, NULL, ch_sig) == 0 && + topo_node_sqlite_member_local_init(db, ch_id) == 0 && chat_feedback_ensure_channel(cs->inst, ch_id) == 0) + mrc = member_sync_apply_record(cs->inst, ch_id, peer, &connection_member); + if (mrc < 0 || sqlite3_exec(db, "RELEASE join_channel", NULL, NULL, NULL) != SQLITE_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join channel commit failed member=%d: %s", mrc, sqlite3_errmsg(db)); + if (sqlite3_exec(db, "ROLLBACK TO join_channel; RELEASE join_channel", NULL, NULL, NULL) != SQLITE_OK) + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join channel rollback failed: %s", sqlite3_errmsg(db)); + cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } + if (cs->info_req_timer) { uasync_cancel_timeout(cs->inst->ua, cs->info_req_timer); cs->info_req_timer = NULL; } + if (chat_core_ensure_channel_ready(cs->inst, ch_id) < 0) { cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } + member_sync_record_committed(cs->inst, ch_id, c_node_id); + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join channel committed ch=%s member=%016llx", ch_id, (unsigned long long)c_node_id); /* построить собственный join_sig */ uint64_t myid = cs->inst->node_id; @@ -1379,17 +1287,21 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, uint8_t msg[128]; join_ts = (uint64_t)ntp_time_get_seconds(cs->inst); int mlen = member_sync_build_join_msg(ch_x25519, ch_ed, myid, my_x25519, join_ts, msg, (int)sizeof(msg)); - if (mlen < 0) { memset(join_sig, 0, 64); DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_INFO_RESP build_join_msg failed", CS_ID); return; } - cs_ed25519_sign(cs->inst->my_ed25519_privkey, msg, (size_t)mlen, join_sig); + if (mlen < 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join signature input invalid"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } + if (cs_ed25519_sign(cs->inst->my_ed25519_privkey, msg, (size_t)mlen, join_sig)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join signing failed"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } } uint8_t my_update_sig[64] = {0}; uint64_t my_update_ts; char own_info[MS_USERINFO_MAX + 1]; - if (chat_core_name_userinfo(cs->inst->name, own_info, sizeof(own_info)) < 0) return; + if (chat_core_name_userinfo(cs->inst->name, own_info, sizeof(own_info)) < 0) { cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } { my_update_ts = (uint64_t)ntp_time_get_seconds(cs->inst); uint8_t umsg[MS_USERINFO_MAX + 73]; int ulen = member_sync_build_update_msg(join_sig, my_update_ts, own_info, umsg, (int)sizeof(umsg)); - if (ulen >= 0) cs_ed25519_sign(cs->inst->my_ed25519_privkey, umsg, (size_t)ulen, my_update_sig); + if (ulen < 0 || cs_ed25519_sign(cs->inst->my_ed25519_privkey, umsg, (size_t)ulen, my_update_sig)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join profile signing failed"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } } /* send JOIN_REQUEST: [join_key:8][member...] */ @@ -1403,44 +1315,42 @@ static void cs_handle_join_info_resp(struct chat_sync* cs, uint64_t peer, uint8_t jm_buf[MS_USERINFO_MAX + 256]; size_t jm_len = 0; if (chat_join_serialize_member(&jm, jm_buf, sizeof(jm_buf), &jm_len) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST serialize failed", CS_ID); - return; + cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } uint8_t jbuf[MS_USERINFO_MAX + 265]; size_t joff = 0; jbuf[joff++] = CS_MSG_JOIN_REQUEST; - memcpy(jbuf + joff, &cs->pending_join_key, 8); joff += 8; + memcpy(jbuf + joff, &cs->join.key, 8); joff += 8; memcpy(jbuf + joff, jm_buf, jm_len); joff += jm_len; - int jrc = cs_send(cs, ch_id, peer, jbuf, joff); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST sent myid=%016llx key=%016llx ch=%s to=0x%016llx rc=%d", - CS_ID, (unsigned long long)myid, (unsigned long long)cs->pending_join_key, ch_id, (unsigned long long)peer, jrc); cs->join_timer = uasync_set_timeout(cs->inst->ua, CS_JOIN_TIMEOUT_MS * 10, cs, cs_join_timeout_cb, "cs_join"); + if (!cs->join_timer) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join ready deadline allocation failed"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } + cs->join.phase = 3; + int jrc = cs_send_attempt(cs, ch_id, peer, jbuf, joff, cs->join.attempt); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST sent myid=%016llx ch=%s to=0x%016llx rc=%d", + CS_ID, (unsigned long long)myid, ch_id, (unsigned long long)peer, jrc); + if (jrc) cs_join_finish(cs, CHAT_CONNECT_ERROR); } /* ─── pending-джойнеры (connection-узел): node_id → прямое соединение ─── */ -static struct join_pending* cs_pending_find(struct chat_sync* cs, uint64_t node_id, const char* ch_id) { - for (struct join_pending* jp = cs->join_pending; jp; jp = jp->next) - if (jp->node_id == node_id && strcmp(jp->ch_id, ch_id) == 0) return jp; - return NULL; -} - -static void cs_pending_add(struct chat_sync* cs, uint64_t node_id, const char* ch_id, struct ETCP_CONN* conn) { - if (cs_pending_find(cs, node_id, ch_id)) return; - struct join_pending* jp = u_malloc(sizeof(*jp)); - if (!jp) return; - jp->node_id = node_id; snprintf(jp->ch_id, sizeof(jp->ch_id), "%s", ch_id); jp->conn = conn; - jp->next = cs->join_pending; cs->join_pending = jp; - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: pending join added node=0x%016llx ch=%s", CS_ID, (unsigned long long)node_id, ch_id); -} - -static void cs_pending_remove(struct chat_sync* cs, uint64_t node_id, const char* ch_id) { - struct join_pending** p = &cs->join_pending; - while (*p) { - if ((*p)->node_id == node_id && strcmp((*p)->ch_id, ch_id) == 0) { - struct join_pending* r = *p; *p = r->next; u_free(r); - return; +static int cs_pending_add(struct chat_sync* cs, uint64_t node_id, const char* ch_id, struct ETCP_CONN* conn, uint64_t attempt) { + (void)conn; + unsigned count = 0; uint64_t now = get_time_tb(); + struct join_pending** link = &cs->join_pending; + while (*link) { + struct join_pending* item = *link; + if (item->expires <= now) { *link = item->next; u_free(item); continue; } + if (item->node_id == node_id) { + if (item->attempt == attempt && !strcmp(item->ch_id, ch_id)) return 0; + count++; } - p = &(*p)->next; + link = &item->next; } + if (count >= 8) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "too many pending join attempts for peer"); return -1; } + struct join_pending* jp = u_calloc(1, sizeof(*jp)); + if (!jp) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "join waiter allocation failed"); return -1; } + jp->attempt = attempt; jp->node_id = node_id; jp->expires = now + CS_JOIN_TIMEOUT_MS * 10; + snprintf(jp->ch_id, sizeof(jp->ch_id), "%s", ch_id); jp->next = cs->join_pending; cs->join_pending = jp; + return 0; } static void cs_pending_free_all(struct chat_sync* cs) { @@ -1449,37 +1359,47 @@ static void cs_pending_free_all(struct chat_sync* cs) { cs->join_pending = NULL; } +static void cs_pending_prune(struct chat_sync* cs) { + uint64_t now = get_time_tb(); + struct join_pending** link = &cs->join_pending; + while (*link) { + struct join_pending* item = *link; + if (item->expires > now) { link = &item->next; continue; } + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join waiter expired peer=%016llx attempt=%016llx", + (unsigned long long)item->node_id, (unsigned long long)item->attempt); + *link = item->next; u_free(item); + } +} + /* ─── отправка JOIN_READY джойнеру + снятие pending (connection-узел) ─── */ static void cs_send_join_ready(struct chat_sync* cs, const char* ch_id, uint64_t node_id) { - uint64_t group_id = strtoull(ch_id, NULL, 10); - uint8_t rbuf[9]; - rbuf[0] = CS_MSG_JOIN_READY; - memcpy(rbuf + 1, &group_id, 8); - cs_send(cs, ch_id, node_id, rbuf, 9); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_READY sent to 0x%016llx ch=%s", - CS_ID, (unsigned long long)node_id, ch_id); - cs_pending_remove(cs, node_id, ch_id); - cs_schedule_sync(cs, group_id, node_id, "join_accepted"); + uint64_t group = strtoull(ch_id, NULL, 10); + uint8_t data[9] = { CS_MSG_JOIN_READY }; memcpy(data + 1, &group, 8); + struct join_pending** link = &cs->join_pending; + while (*link) { + struct join_pending* pending = *link; + if (pending->node_id != node_id || strcmp(pending->ch_id, ch_id)) { link = &pending->next; continue; } + if (cs_send_attempt(cs, ch_id, node_id, data, sizeof(data), pending->attempt)) { link = &pending->next; continue; } + DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "join ready peer=%016llx attempt=%016llx", + (unsigned long long)node_id, (unsigned long long)pending->attempt); + *link = pending->next; u_free(pending); + } + cs_schedule_sync(cs, group, node_id, "join_accepted"); } -/* ─── коллбэк member_sync: пришёл рекорд мембера → отправить JOIN_READY джойнеру ─── */ - -static void cs_on_member_applied(const char* ch_id, uint64_t node_id, - const uint8_t* x25519, uint64_t signed_by, - const uint8_t* signature, void* arg) { +static void cs_on_member_applied(const char* ch_id, uint64_t node_id, const uint8_t* x25519, + uint64_t signed_by, const uint8_t* signature, void* arg) { (void)x25519; (void)signed_by; (void)signature; - struct chat_sync* cs = (struct chat_sync*)arg; - if (!cs || !cs->initialized) return; - struct join_pending* jp = cs_pending_find(cs, node_id, ch_id); - if (!jp || !jp->conn) return; - cs_send_join_ready(cs, ch_id, node_id); + struct chat_sync* cs = arg; + if (cs && cs->initialized && topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, node_id)) + cs_send_join_ready(cs, ch_id, node_id); } /* ─── JOIN_REQUEST (0x12): joiner → connection-узел ─── */ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, - const char* ch_id, const uint8_t* pl, size_t len) { + const char* ch_id, const uint8_t* pl, size_t len, uint64_t attempt) { /* payload: [join_key:8][member...] */ if (len < 8 + 1) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST too short len=%zu peer=%016llx", CS_ID, len, (unsigned long long)peer); return; } uint64_t join_key; memcpy(&join_key, pl, 8); @@ -1491,12 +1411,12 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST parse failed ch=%s from=%016llx", CS_ID, ch_id, (unsigned long long)peer); return; } - DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST ch=%s joiner=%016llx from=%016llx key=%016llx", - CS_ID, ch_id, (unsigned long long)m.node_id, (unsigned long long)peer, (unsigned long long)join_key); + DEBUG_TRACE(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST ch=%s joiner=%016llx from=%016llx", + CS_ID, ch_id, (unsigned long long)m.node_id, (unsigned long long)peer); /* привязка идентичности: pubkey из ETCP-соединения == заявленный x25519 */ struct ETCP_CONN* jconn = cs_find_conn_for_node(cs->inst, peer); - if (!jconn || memcmp(jconn->crypto_ctx.peer_public_key, m.x25519, 32) != 0) { + if (!jconn || m.node_id != peer || memcmp(jconn->crypto_ctx.peer_public_key, m.x25519, 32) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST identity mismatch joiner=0x%016llx peer=0x%016llx — drop", CS_ID, (unsigned long long)m.node_id, (unsigned long long)peer); return; @@ -1507,7 +1427,7 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, if (inviter == 0) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_REQUEST invalid/expired join key ch=%s joiner=0x%016llx", CS_ID, ch_id, (unsigned long long)m.node_id); - uint8_t err[1] = { CS_MSG_ERROR }; cs_send(cs, ch_id, peer, err, 1); + uint8_t err[1] = { CS_MSG_ERROR }; cs_send_attempt(cs, ch_id, peer, err, 1, attempt); return; } @@ -1515,7 +1435,7 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, локально либо через Merkle; существующий транспорт обслуживает JOIN_REQUEST. */ /* добавить в pending (ждут JOIN_READY по приходу рекорда через merkle) */ - cs_pending_add(cs, m.node_id, ch_id, jconn); + if (cs_pending_add(cs, m.node_id, ch_id, jconn, attempt) < 0) return; /* форвард инвайтеру (или локальная обработка, если инвайтер == мы) */ if (inviter == cs->inst->node_id) { @@ -1523,18 +1443,17 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, /* мы и есть connection-узел + инвайтер: рекорд добавлен локально, merkle-apply-коллбэк (cs_on_member_applied) здесь не сработает — отправляем JOIN_READY напрямую, если мембер реально добавлен. */ - if (topo_node_sqlite_member_in_channel(cs->inst->topo_sqlite_db, ch_id, m.node_id)) + if (topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, m.node_id)) { cs_send_join_ready(cs, ch_id, m.node_id); - /* join завершён — снять pending-состояние инвайтера и таймер ожидания */ - if (cs->pending_invite_ch_id == channel_id && cs->pending_invite_node_id == m.node_id) { - if (cs->invite_send_timer) { uasync_cancel_timeout(cs->inst->ua, cs->invite_send_timer); cs->invite_send_timer = NULL; } - cs->pending_invite_ch_id = 0; - cs->pending_invite_node_id = 0; - cs->pending_invite_is_inviter = 0; - cs_join_release_transport(cs); + if (cs->join.inviter && cs->join.channel == channel_id && cs->join.peer == m.node_id && cs->join.key == join_key) + cs_join_finish(cs, 0); + } else { + uint8_t error = CS_MSG_ERROR; cs_send_attempt(cs, ch_id, peer, &error, 1, attempt); } } else { - chat_join_forward_request(cs->inst, channel_id, inviter, join_key, &m); + if (chat_join_forward_request(cs->inst, channel_id, inviter, join_key, &m) < 0) { + uint8_t error = CS_MSG_ERROR; cs_send_attempt(cs, ch_id, peer, &error, 1, attempt); + } else if (topo_node_sqlite_member_allowed(cs->inst->topo_sqlite_db, ch_id, m.node_id)) cs_send_join_ready(cs, ch_id, m.node_id); } cs_refresh_channels(cs); @@ -1545,9 +1464,11 @@ static void cs_handle_join_request(struct chat_sync* cs, uint64_t peer, static void cs_handle_join_ready(struct chat_sync* cs, uint64_t peer, const char* ch_id, const uint8_t* pl, size_t len) { - if (cs->join_timer) { uasync_cancel_timeout(cs->inst->ua, cs->join_timer); cs->join_timer = NULL; } - if (len < 8) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_READY too short len=%zu peer=%016llx", CS_ID, len, (unsigned long long)peer); return; } + if (len != 8) { DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "invalid JOIN_READY size=%zu", len); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } uint64_t group_id; memcpy(&group_id, pl, 8); + if (group_id != cs->join.channel) { + DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "JOIN_READY group mismatch"); cs_join_finish(cs, CHAT_CONNECT_ERROR); return; + } DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_READY ch=%s group=%016llx from=%016llx — joining complete, starting BGP", CS_ID, ch_id, (unsigned long long)group_id, (unsigned long long)peer); @@ -1569,14 +1490,7 @@ static void cs_handle_join_ready(struct chat_sync* cs, uint64_t peer, chat_event_post(cs->inst, CHAT_EVT_CHANNEL_UPDATED, evt, 1 + ch_id_len); cs_post_channel_online(cs, ch_id); - if (cs->pending_invite_ch_id != 0) { - uint64_t ch_id_num = strtoull(ch_id, NULL, 10); - uint8_t cevt[20]; memcpy(cevt, &peer, 8); int r = 0; memcpy(cevt + 8, &r, 4); memcpy(cevt + 12, &ch_id_num, 8); - chat_event_post(cs->inst, CHAT_EVT_CONNECT_RESULT, cevt, 20); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: JOIN_READY invite success ch=%s peer=%016llx", CS_ID, ch_id, (unsigned long long)peer); - cs->pending_invite_node_id = 0; cs->pending_invite_ch_id = 0; cs->pending_join_key = 0; - cs_join_release_transport(cs); - } + cs_join_finish(cs, 0); } /* ─── CHANNEL_INVITE (0x0F): inviter → joiner (push-приглашение в канал) ─── */ @@ -1650,16 +1564,8 @@ static void cs_handle_channel_invite(struct chat_sync* cs, uint64_t peer, if (policy == 0) { /* autojoin */ DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_INVITE autojoin ch=%s from=%016llx owner=%016llx name=%s", CS_ID, ch_id, (unsigned long long)peer, (unsigned long long)owner, name); - /* Save channel and start join handshake */ - int cprc = topo_node_sqlite_channel_put(cs->inst->topo_sqlite_db, - ch_id, name, owner, x25519, NULL, ed_pub, NULL, ch_sig); - uint64_t ch_x_prefix; memcpy(&ch_x_prefix, x25519, sizeof(ch_x_prefix)); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: CHANNEL_INVITE channel_put rc=%d ch=%s ch_x25519=%016llx", - CS_ID, cprc, ch_id, (unsigned long long)ch_x_prefix); - chat_core_ensure_channel_ready(cs->inst, ch_id); - /* Initiate join: connect back and send JOIN_INFO_REQ с зарегистрированным ключом */ - cs->pending_join_key = join_key; - chat_sync_join_channel(cs->inst, ch_id, peer); + /* Описание и участника сохраняет проверенный JOIN_INFO_RESP. */ + cs_join_existing(cs->inst, ch_id, peer, join_key); return; } @@ -1726,6 +1632,7 @@ static int cs_send_channel_invite(struct chat_sync* cs, struct UTUN_INSTANCE* in return -1; } if (chat_join_store_local_key(inst, strtoull(ch_id, NULL, 10), join_key) != 0) return -1; + if (cs->join.inviter && cs->join.peer == target_node_id) cs->join.key = join_key; uint8_t buf[MS_USERINFO_MAX + 768]; size_t boff = 0; buf[boff++] = CS_MSG_CHANNEL_INVITE; @@ -1747,212 +1654,21 @@ static int cs_send_channel_invite(struct chat_sync* cs, struct UTUN_INSTANCE* in buf[boff++] = (uint8_t)(il >> 8); buf[boff++] = (uint8_t)il; memcpy(buf + boff, juser, il); boff += il; memcpy(buf + boff, &join_key, 8); boff += 8; int rc = cs_send(cs, ch_id, target_node_id, buf, boff); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: sent CHANNEL_INVITE to 0x%016llx ch=%s key=%016llx rc=%d", - CS_ID, (unsigned long long)target_node_id, ch_id, (unsigned long long)join_key, rc); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: sent CHANNEL_INVITE to 0x%016llx ch=%s rc=%d", + CS_ID, (unsigned long long)target_node_id, ch_id, rc); return rc; } /* ─── chat_sync_invite_to_channel: пригласить узел в наш канал ─── */ -void chat_sync_invite_to_channel(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id) { +void chat_sync_invite_to_channel(struct UTUN_INSTANCE* inst, const char* channel, uint64_t peer) { struct chat_sync* cs = cs_of(inst); - if (!cs) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel: cs=NULL, chat_sync not initialized", CS_ID); return; } - if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel: inst=NULL", CS_ID); return; } - if (!ch_id || !ch_id[0]) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel: ch_id NULL/empty", CS_ID); return; } - if (target_node_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel: target_node_id=0", CS_ID); return; } - if (target_node_id == inst->node_id) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel: self-invite rejected self=0x%016llx", CS_ID, (unsigned long long)inst->node_id); return; } - - uint64_t channel_id = strtoull(ch_id, NULL, 10); - if (channel_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_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_MEMBER_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 direct (ncd) */ - 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_MEMBER_SYNC, "%s: invite_to_channel — existing handle for node 0x%016llx, waiting for conn_up", - CS_ID, (unsigned long long)target_node_id); - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - return; - } - } - /* Адреса грузятся из node_addresses в БД внутри node_conn_direct_open */ - int r = cs_join_hold_transport(cs, target_node_id, NULL); - if (r < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel topo_group_invite_to_channel failed rc=%d", CS_ID, r); - } - } else { - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_to_channel — no topo_groups, cannot connect", CS_ID); - return; - } - /* Mark pending: after conn_up, we send CHANNEL_INVITE. */ - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - cs->pending_invite_is_inviter = 1; - return; - } - - /* Already connected — send invite now, wait for joiner to respond */ - if (cs_join_hold_transport(cs, target_node_id, NULL) < 0) return; - uasync_cancel_timeout(inst->ua, cs->join_transport_timer); cs->join_transport_timer = NULL; - cs_send_channel_invite(cs, inst, ch_id, target_node_id, ch_name, owner, ch_x25519, ch_ed, ch_sig); - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - cs->pending_invite_is_inviter = 1; - if (cs->invite_send_timer) { uasync_cancel_timeout(inst->ua, cs->invite_send_timer); } - cs->invite_send_timer = uasync_set_timeout(inst->ua, - CS_INVITE_SEND_TIMEOUT_MS * 10, cs, cs_invite_send_timeout_cb, "cs_invite_send"); -} - -/* ─── chat_sync_invite_to_channel_with_addrs: как invite_to_channel, но с адресами ─── */ - -struct cs_invite_wrap { - struct UTUN_INSTANCE* inst; - uint64_t channel_id; - uint64_t target_node_id; - uint8_t pubkey[32]; - int addr_count; - int addrs_len; - uint8_t addrs_data[]; /* flexible array */ -}; - -static void cs_invite_trampoline(void* arg) { - struct cs_invite_wrap* w = (struct cs_invite_wrap*)arg; - struct chat_sync* cs = cs_of(w->inst); - if (!cs || !cs->inst) { u_free(w); return; } - struct UTUN_INSTANCE* inst = cs->inst; - uint64_t channel_id = w->channel_id; - uint64_t target_node_id = w->target_node_id; - - if (channel_id == 0 || target_node_id == 0 || target_node_id == inst->node_id) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs invalid params ch=%llu target=0x%016llx", - CS_ID, (unsigned long long)channel_id, (unsigned long long)target_node_id); - u_free(w); return; - } - - char ch_id[64]; snprintf(ch_id, sizeof(ch_id), "%llu", (unsigned long long)channel_id); - - /* Check if already connected */ - struct ETCP_CONN* conn = cs_find_conn_for_node(inst, target_node_id); - if (conn && conn->links_up && conn->initialized) { - char ch_name[128]; uint8_t ch_x2[32], ch_ed2[32], ch_sig[64]; uint64_t ch_owner; - if (topo_node_sqlite_channel_get(inst->topo_sqlite_db, - ch_id, ch_name, (int)sizeof(ch_name), &ch_owner, ch_x2, ch_ed2, ch_sig) == 0) { - if (cs_join_hold_transport(cs, target_node_id, NULL) < 0) { u_free(w); return; } - uasync_cancel_timeout(inst->ua, cs->join_transport_timer); cs->join_transport_timer = NULL; - cs_send_channel_invite(cs, inst, ch_id, target_node_id, ch_name, ch_owner, ch_x2, ch_ed2, ch_sig); - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - cs->pending_invite_is_inviter = 1; - if (cs->invite_send_timer) { uasync_cancel_timeout(inst->ua, cs->invite_send_timer); } - cs->invite_send_timer = uasync_set_timeout(inst->ua, - CS_INVITE_SEND_TIMEOUT_MS * 10, cs, cs_invite_send_timeout_cb, "cs_invite_send"); - } - u_free(w); return; - } - - /* Not connected — build TOPO_NODE with addresses and connect */ - if (!inst->topo_groups) { - DEBUG_WARN(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs — no topo_groups, cannot connect", CS_ID); - u_free(w); return; - } - - struct TOPO_NODE* ni = u_calloc(1, sizeof(*ni)); - if (!ni) { u_free(w); return; } - ni->node_id = target_node_id; memcpy(ni->public_key, w->pubkey, 32); - - const uint8_t* ap = w->addrs_data; - const uint8_t* ap_end = ap + w->addrs_len; - uint8_t r_short_id[8], r_pubkey[32], r_ver[3]; char r_sname[TOPO_REALITY_SNAME_MAX]; - int has_reality = invite_reality_parse(&ap, ap_end, r_short_id, r_pubkey, r_ver, r_sname, sizeof(r_sname)); - for (int i = 0; i < w->addr_count && ap + 1 <= ap_end; i++) { - uint8_t family = *ap++; uint8_t sock_id = *ap++; uint8_t proto = *ap++; - if (family == 4 && ap + 6 <= ap_end) { - struct TOPO_ADDR4* a4 = u_calloc(1, sizeof(*a4)); - if (!a4) break; - memcpy(a4->addr, ap, 4); ap += 4; - a4->port = ((uint16_t)ap[0] << 8) | ap[1]; ap += 2; - a4->type = TOPO_ADDR_NAT; a4->protocol = proto; a4->socket_id = sock_id; - a4->next = ni->v4_addrs; ni->v4_addrs = a4; - if (has_reality && (proto & 0x04)) invite_reality_sock_append(ni, sock_id, r_short_id, r_pubkey, r_ver, r_sname); - } else if (family == 6 && ap + 18 <= ap_end) { - struct TOPO_ADDR6* a6 = u_calloc(1, sizeof(*a6)); - if (!a6) break; - memcpy(a6->addr, ap, 16); ap += 16; - a6->port = ((uint16_t)ap[0] << 8) | ap[1]; ap += 2; - a6->type = TOPO_ADDR_NAT; a6->protocol = proto; a6->socket_id = sock_id; - a6->next = ni->v6_addrs; ni->v6_addrs = a6; - if (has_reality && (proto & 0x04)) invite_reality_sock_append(ni, sock_id, r_short_id, r_pubkey, r_ver, r_sname); - } else { break; } - } - - if (!ni->v4_addrs && !ni->v6_addrs) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs no valid addrs for node=0x%016llx", - CS_ID, (unsigned long long)target_node_id); - u_free(ni); u_free(w); return; - } - - cs->pending_invite_ch_id = channel_id; - cs->pending_invite_node_id = target_node_id; - cs->pending_invite_is_inviter = 1; - - int r = cs_join_hold_transport(cs, target_node_id, ni); - if (r < 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs topo_group_invite_to_channel failed rc=%d", CS_ID, r); - cs->pending_invite_node_id = 0; cs->pending_invite_ch_id = 0; - cs->pending_invite_is_inviter = 0; - } - - while (ni->v4_addrs) { struct TOPO_ADDR4* n = ni->v4_addrs->next; u_free(ni->v4_addrs); ni->v4_addrs = n; } - while (ni->v6_addrs) { struct TOPO_ADDR6* n = ni->v6_addrs->next; u_free(ni->v6_addrs); ni->v6_addrs = n; } - u_free(ni); - u_free(w); -} - -void chat_sync_invite_to_channel_with_addrs( - struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id, - const uint8_t* pubkey_bin, const uint8_t* addrs_data, int addr_count, - int addrs_data_len) -{ - struct chat_sync* cs = cs_of(inst); - if (!cs || !inst || !ch_id || !ch_id[0] || !pubkey_bin || !addrs_data || addr_count <= 0 || addrs_data_len <= 0) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs invalid args", CS_ID); - return; - } - uint64_t channel_id = strtoull(ch_id, NULL, 10); - if (channel_id == 0 || target_node_id == 0 || target_node_id == inst->node_id) { - DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs invalid params ch=%s target=0x%016llx", - CS_ID, ch_id, (unsigned long long)target_node_id); - return; - } - - struct cs_invite_wrap* w = u_calloc(1, sizeof(*w) + (size_t)addrs_data_len); - if (!w) return; - w->inst = inst; - w->channel_id = channel_id; - w->target_node_id = target_node_id; - memcpy(w->pubkey, pubkey_bin, 32); - w->addr_count = addr_count; - w->addrs_len = addrs_data_len; - memcpy(w->addrs_data, addrs_data, (size_t)addrs_data_len); - - uasync_post(inst->ua, cs_invite_trampoline, w); - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: invite_with_addrs posted to uasync ch=%s target=0x%016llx addrs=%d", - CS_ID, ch_id, (unsigned long long)target_node_id, addr_count); + uint64_t group = channel ? strtoull(channel, NULL, 10) : 0; + if (cs_join_reserve(cs, group, peer, 0, 1)) return; + if (cs_join_hold_transport(cs, peer, NULL) < 0) { cs_join_finish(cs, CHAT_CONNECT_ERROR); return; } + struct ETCP_CONN* conn = node_conn_direct_get_conn(cs->join.handle); + if (conn && conn->initialized && conn->links_up && cs_start_channel_offer(cs, peer) < 0) + cs_join_finish(cs, CHAT_CONNECT_ERROR); } /* ─── accept/deny incoming invite ─── */ @@ -1979,11 +1695,9 @@ void chat_sync_accept_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, ui CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id); return; } - DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: accept_invite ch=%llu inviter=0x%016llx key=%016llx", - CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id, (unsigned long long)join_key); - chat_core_ensure_channel_ready(inst, ch_id); - cs->pending_join_key = join_key; - chat_sync_join_channel(inst, ch_id, inviter_node_id); + DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "%s: accept_invite ch=%llu inviter=0x%016llx", + CS_ID, (unsigned long long)channel_id, (unsigned long long)inviter_node_id); + cs_join_existing(inst, ch_id, inviter_node_id, join_key); } void chat_sync_deny_invite(struct UTUN_INSTANCE* inst, uint64_t channel_id, uint64_t inviter_node_id) { diff --git a/src/chat/chat_sync.h b/src/chat/chat_sync.h index bcac6c09..1b174a0c 100644 --- a/src/chat/chat_sync.h +++ b/src/chat/chat_sync.h @@ -5,8 +5,8 @@ * Отложенные запросы объединяются по (канал, пир); активными обменами и обновлениями владеет Merkle. * Вход по ссылке держит отдельный NCD handle до результата join, а не только до UP. * JOIN_INFO_REQ → JOIN_INFO_RESP → JOIN_REQUEST → JOIN_READY; правила — в chat_join.h. - * Жизненным циклом управляет chat_service_start/stop. Вызовы — в потоке uasync, - * кроме явно отмеченной обёртки, которая копирует аргументы и постит работу в этот поток. + * Жизненным циклом управляет chat_service_start/stop. Вызовы — в потоке uasync. + * Вход из GUI/JNI через отдельный chat_join_port, который копирует ссылку и удерживает очередь до close. */ #ifndef CHAT_SYNC_H @@ -41,6 +41,11 @@ struct UASYNC; #define CS_MSG_JOIN_READY 0x13 /* connection → joiner: {group_id:8} */ #define CS_MSG_SOUND_SETTING 0x14 /* peer → server: {sound_on:1} — per-chat play sound on message */ +/* Управление JOIN (INFO_REQ/RESP, REQUEST, READY, ERROR): после type идёт attempt_id:8BE, + * затем payload, описанный выше. Ненулевой случайный ID новый для каждой локальной попытки; + * ответы проверяются по (peer, group, attempt_id, phase). CHANNEL_INVITE переносит предложение + * с join_key; получатель создаёт собственный attempt_id при принятии предложения. */ + /* Protocol constants */ #define CS_SEND_DATA_MAX 32 #define CS_MAX_SPARSE 16 @@ -55,31 +60,31 @@ struct UASYNC; /* Создать контекст, обработчики join и member_sync. 0/-1; повтор допустим. */ int chat_sync_init(struct UTUN_INSTANCE* inst); -/* Отменить ожидания join, синхронизацию и таймеры; не удаляет данные каналов. */ +/* uasync: закрыть приём команд и отменить локальный JOIN до остановки workers. */ +void chat_sync_quiesce(struct UTUN_INSTANCE* inst); +/* uasync: отменить только активный request; чужая попытка сохраняется. */ +void chat_sync_cancel_join(struct UTUN_INSTANCE* inst, uint64_t request); +/* Отменить ожидания, синхронизацию и таймеры, освободить контекст; данные каналов сохраняются. */ void chat_sync_destroy(struct UTUN_INSTANCE* inst); /* Сейчас пустая функция. Для подключения использовать chat_core_connect_node с каналом. */ void chat_sync_connect_node(struct UTUN_INSTANCE* inst, uint64_t node_id); -/* Подключиться к пиру по данным invite-ссылки (вызывается из uasync-потока). - join_key — 64-битный ключ входа из ссылки (регистрируется инвайтером на connection-узле). - password может быть NULL — тогда передаётся пустой пароль. */ -void chat_sync_connect_from_invite(struct UTUN_INSTANCE* inst, 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, - const char* password, uint64_t join_key); +/* Локальная попытка JOIN: uasync. Отрицательный возврат — отказ запуска без события; + * 0 — запущено, CHAT_EVT_CONNECT_RESULT ровно один раз, включая stop/delete. + * request=0 — без адресата UI; ненулевые ID вызывающий не переиспользует до завершения. + * link заимствован до возврата; request — корреляция интерфейса, не сетевой attempt_id. */ +#define CHAT_CONNECT_ERROR -1 +#define CHAT_CONNECT_TIMEOUT 2 +#define CHAT_CONNECT_INVALID -7 +#define CHAT_CONNECT_BUSY -8 +#define CHAT_CONNECT_CANCELLED -9 +int chat_sync_join_link(struct UTUN_INSTANCE* inst, uint64_t request, const char* link, size_t len); +/* Единый формат результата: peer:8,result:4,channel:8,request:8, native; только uasync. */ +void chat_sync_join_result(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, uint64_t peer, int result); -/* Пригласить target_node_id в канал ch_id с адресами из invite-ссылки (inviter → joiner). - Принимает pubkey и адреса целевого узла. Если соединения ещё нет — строит TOPO_NODE - с адресами, удерживает NCD для join, и по UP отправляет CS_MSG_CHANNEL_INVITE. - Если соединение уже есть — отправляет CHANNEL_INVITE сразу. - Копирует pubkey_bin и addrs_data (вызывающий может освободить после возврата). - Вызов из любого потока — внутри постит в uasync. */ -void chat_sync_invite_to_channel_with_addrs( - struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t target_node_id, - const uint8_t* pubkey_bin, const uint8_t* addrs_data, int addr_count, - int addrs_data_len); +/* uasync: пригласить адресата ссылки в свой channel; те же правила запуска/результата. */ +int chat_sync_offer_link(struct UTUN_INSTANCE* inst, uint64_t request, uint64_t channel, const char* link, size_t len); /* Пригласить target_node_id в канал ch_id (inviter → joiner). Отправляет CS_MSG_CHANNEL_INVITE с описанием канала, подписями/профилем инвайтера и join_key. */ diff --git a/src/chat/invite_build.c b/src/chat/invite_build.c index 4069a09f..3c6b0735 100644 --- a/src/chat/invite_build.c +++ b/src/chat/invite_build.c @@ -371,7 +371,7 @@ struct chat_invite_build { struct chat_join_registration* registration; chat_invite_ready_fn callback; void* arg; - char link[1024]; + char link[INVITE_LINK_MAX]; }; static void invite_registered(void* arg, int result) { diff --git a/src/chat/invite_link.c b/src/chat/invite_link.c index 47042888..e9f4bf0b 100644 --- a/src/chat/invite_link.c +++ b/src/chat/invite_link.c @@ -1,234 +1,174 @@ -/* - * invite_link.c — encode/decode invite-ссылок utun:// - * - * Перенесено из tools/chatgui-android/libutun_lite/invite_link_c.c - * Формат совместим с десктопной Qt-версией (tools/chatgui/src/invite_link.cpp). - */ +/* Общий строгий кодек приглашений. До полной проверки выходы не изменяются. */ #include "invite_link.h" #include "../transport_layer/secure_channel.h" #include "../../lib/debug_config.h" -#include -#include +#include #include +#include -#define INVITE_PREFIX "utun://" -#define INVITE_PREFIX_LEN 7 +#define INVITE_RAW_MAX 1157u +#define INVITE_PREFIX "utun://" -static const char base64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -static int base64_char_val(char c) { - if (c >= 'A' && c <= 'Z') return c - 'A'; - if (c >= 'a' && c <= 'z') return c - 'a' + 26; - if (c >= '0' && c <= '9') return c - '0' + 52; - if (c == '+') return 62; - if (c == '/') return 63; +static int reject(char* error, size_t size, const char* reason) { + if (error && size) snprintf(error, size, "%s", reason); + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "invite rejected: %s", reason); return -1; } -static int base64_decode(const char* in, size_t in_len, uint8_t* out, size_t out_cap) { - size_t opos = 0; int group[4]; int gi = 0; - for (size_t i = 0; i < in_len && in[i] != '='; i++) { - int v = base64_char_val(in[i]); - if (v < 0) return -1; - group[gi++] = v; - if (gi == 4) { - out[opos++] = (uint8_t)((group[0] << 2) | (group[1] >> 4)); - if (opos >= out_cap) return -1; - out[opos++] = (uint8_t)(((group[1] & 0xF) << 4) | (group[2] >> 2)); - if (opos >= out_cap) return -1; - out[opos++] = (uint8_t)(((group[2] & 0x3) << 6) | group[3]); - if (opos >= out_cap) return -1; - gi = 0; - } - } - if (gi == 2) { out[opos++] = (uint8_t)((group[0] << 2) | (group[1] >> 4)); } - else if (gi == 3) { - out[opos++] = (uint8_t)((group[0] << 2) | (group[1] >> 4)); - out[opos++] = (uint8_t)(((group[1] & 0xF) << 4) | (group[2] >> 2)); - } - return (int)opos; +static int nonzero(const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; i++) if (data[i]) return 1; + return 0; } -int invite_link_decode(const char* link, size_t link_len, struct InviteData* out, - char* error_buf, size_t error_buf_size) { - if (!link || !out) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "null argument"); return -1; } - if (link_len < INVITE_PREFIX_LEN || strncmp(link, INVITE_PREFIX, INVITE_PREFIX_LEN) != 0) { - if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "invalid prefix (expected utun://)"); return -1; - } - const char* b64 = link + INVITE_PREFIX_LEN; - size_t b64_len = link_len - INVITE_PREFIX_LEN; - uint8_t raw[4096]; - int raw_len = base64_decode(b64, b64_len, raw, sizeof(raw)); - if (raw_len < 0) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "base64 decode failed"); return -1; } - if (raw_len < 11) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "blob too short (%d bytes)", raw_len); return -1; } - - int off = 0; - uint8_t ver = raw[off++]; - if (ver != INVITE_LINK_VERSION && ver != 0x02 && ver != 0x01) { - 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; - out->reality.has = 0; - - if (ver == 0x02 || ver == INVITE_LINK_VERSION) { - 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; } - uint64_t chId = 0; - for (int i = 0; i < 8; i++) chId = (chId << 8) | raw[off++]; - out->channelId = chId; - - if (off + 8 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at join_key"); return -1; } - uint64_t jk = 0; - for (int i = 0; i < 8; i++) jk = (jk << 8) | raw[off++]; - out->join_key = jk; - - if (ver == INVITE_LINK_VERSION) { - if (off + 1 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at reality"); return -1; } - if (raw[off++]) { - if (off + 8 + 32 + 3 + 1 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at reality params"); return -1; } - memcpy(out->reality.short_id, raw + off, 8); off += 8; - memcpy(out->reality.server_pubkey, raw + off, 32); off += 32; - memcpy(out->reality.version, raw + off, 3); off += 3; - uint8_t snlen = raw[off++]; - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1; - if (off + snlen > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at reality server_name"); return -1; } - memcpy(out->reality.server_name, raw + off, snlen); out->reality.server_name[snlen] = '\0'; - off += snlen; - out->reality.has = 1; - } - } - - memset(out->pubkey, 0, INVITE_PUBKEY_SIZE); - out->addrCount = 0; - - int first_block = 1; - while (off + 1 <= raw_len) { - uint8_t header = raw[off++]; - int cnt = (header & 0x03) + 1; - if (cnt < 1 || cnt > 4) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "invalid addr count %d", cnt); return -1; } - if (off + 32 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at pubkey"); return -1; } - if (first_block) { memcpy(out->pubkey, raw + off, INVITE_PUBKEY_SIZE); out->nodeId = sc_derive_node_id_from_pubkey(out->pubkey); first_block = 0; } - off += 32; - for (int j = 0; j < cnt; j++) { - int is_v6 = (header >> (2 + j)) & 1; - int ip_len = is_v6 ? 16 : 4; - if (off + 2 + ip_len + 2 > raw_len) { if (error_buf && error_buf_size) snprintf(error_buf, error_buf_size, "truncated at addr %d", j); return -1; } - if (out->addrCount >= INVITE_ADDR_MAX) break; - struct InviteAddr* a = &out->addrs[out->addrCount++]; - a->socketId = raw[off++]; a->proto = raw[off++]; - a->family = is_v6 ? 6 : 4; - memcpy(a->address, raw + off, (size_t)ip_len); off += ip_len; - a->port = (uint16_t)((raw[off] << 8) | raw[off + 1]); off += 2; - } +int invite_link_validate(const struct InviteData* d) { + if (!d || !d->channelId || d->channelId > INT64_MAX || !d->join_key || !nonzero(d->pubkey, 32) || !sc_derive_node_id_from_pubkey(d->pubkey)) + return reject(NULL, 0, "missing channel, join key or node identity"); + if (!d->addrCount || d->addrCount > INVITE_ADDR_MAX) return reject(NULL, 0, "invalid address count"); + if (d->reality.has > 1) return reject(NULL, 0, "invalid REALITY marker"); + if (d->reality.has && (!memchr(d->reality.server_name, 0, sizeof(d->reality.server_name)) || + !d->reality.server_name[0] || !nonzero(d->reality.server_pubkey, 32))) + return reject(NULL, 0, "invalid REALITY parameters"); + for (unsigned i = 0; i < d->addrCount; i++) { + const struct InviteAddr* a = &d->addrs[i]; + if ((a->family != 4 && a->family != 6) || !a->port || + (a->proto != INVITE_PROTO_UDP && a->proto != INVITE_PROTO_TCP && a->proto != (INVITE_PROTO_TCP | INVITE_PROTO_REALITY))) + return reject(NULL, 0, "invalid address family, port or protocol"); + if ((a->proto & INVITE_PROTO_REALITY) && !d->reality.has) return reject(NULL, 0, "missing REALITY parameters"); } return 0; } -int invite_serialize_addrs(const struct InviteData* data, uint8_t* buf, size_t buf_size) { - if (!data || !buf) return -1; - size_t needed = 1; - if (data->reality.has) { - size_t snlen = strlen(data->reality.server_name); - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1; - needed += 8 + 32 + 3 + 1 + snlen; +int invite_link_decode(const char* link, size_t len, struct InviteData* out, char* error, size_t error_size) { + if (!link || !out || len < 7 || len >= INVITE_LINK_MAX || memcmp(link, INVITE_PREFIX, 7)) + return reject(error, error_size, "invalid invitation length or prefix"); + size_t encoded = len - 7; + if (!encoded || encoded % 4 || encoded > 4 * ((INVITE_RAW_MAX + 2) / 3)) + return reject(error, error_size, "invalid base64 length"); + const char* b64 = link + 7; + size_t padding = (b64[encoded - 1] == '=') + (encoded > 1 && b64[encoded - 2] == '='); + for (size_t i = 0; i < encoded - padding; i++) { + unsigned char c = (unsigned char)b64[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/')) + return reject(error, error_size, "invalid base64 alphabet"); } - for (int i = 0; i < (int)data->addrCount; i++) needed += (data->addrs[i].family == 6) ? 21U : 9U; - if (needed > buf_size) return -1; + uint8_t raw[INVITE_RAW_MAX + 3]; + int decoded = EVP_DecodeBlock(raw, (const unsigned char*)b64, (int)encoded); + if (decoded < 0 || (size_t)decoded < padding || (size_t)decoded - padding > INVITE_RAW_MAX) + return reject(error, error_size, "invalid base64 data"); + size_t count = (size_t)decoded - padding; + unsigned char canonical[4 * ((INVITE_RAW_MAX + 2) / 3) + 1]; + int canonical_len = EVP_EncodeBlock(canonical, raw, (int)count); + if ((size_t)canonical_len != encoded || memcmp(canonical, b64, encoded)) + return reject(error, error_size, "noncanonical base64"); + struct InviteData value = {0}; size_t pos = 0; - buf[pos++] = data->reality.has ? 1 : 0; +#define NEED(n) do { if ((size_t)(n) > count - pos) return reject(error, error_size, "truncated invitation"); } while (0) + NEED(2); + if (raw[pos++] != INVITE_LINK_VERSION) return reject(error, error_size, "unsupported invitation version"); + value.password_len = raw[pos++]; + if (value.password_len >= INVITE_PASS_MAX) return reject(error, error_size, "password too long"); + NEED(value.password_len); + if (memchr(raw + pos, 0, value.password_len)) return reject(error, error_size, "password contains NUL"); + memcpy(value.password, raw + pos, value.password_len); pos += value.password_len; + NEED(17); + for (unsigned i = 0; i < 8; i++) value.channelId = (value.channelId << 8) | raw[pos++]; + for (unsigned i = 0; i < 8; i++) value.join_key = (value.join_key << 8) | raw[pos++]; + value.reality.has = raw[pos++]; + if (value.reality.has > 1) return reject(error, error_size, "invalid REALITY marker"); + if (value.reality.has) { + NEED(44); + memcpy(value.reality.short_id, raw + pos, 8); pos += 8; + memcpy(value.reality.server_pubkey, raw + pos, 32); pos += 32; + memcpy(value.reality.version, raw + pos, 3); pos += 3; + size_t size = raw[pos++]; + if (!size || size >= INVITE_SNAME_MAX) return reject(error, error_size, "invalid REALITY server name length"); + NEED(size); + if (memchr(raw + pos, 0, size)) return reject(error, error_size, "REALITY server name contains NUL"); + memcpy(value.reality.server_name, raw + pos, size); pos += size; + } + while (pos < count) { + NEED(33); + uint8_t header = raw[pos++]; + unsigned addresses = (header & 3) + 1; + if ((header >> (2 + addresses)) || value.addrCount + addresses > INVITE_ADDR_MAX) + return reject(error, error_size, "invalid address block"); + if (!value.addrCount) memcpy(value.pubkey, raw + pos, 32); + else if (memcmp(value.pubkey, raw + pos, 32)) return reject(error, error_size, "conflicting node keys"); + pos += 32; + for (unsigned i = 0; i < addresses; i++) { + struct InviteAddr* a = &value.addrs[value.addrCount++]; + a->family = (header & (1u << (2 + i))) ? 6 : 4; + size_t ip_len = a->family == 6 ? 16 : 4; + NEED(4 + ip_len); + a->socketId = raw[pos++]; a->proto = raw[pos++]; + memcpy(a->address, raw + pos, ip_len); pos += ip_len; + a->port = (uint16_t)((raw[pos] << 8) | raw[pos + 1]); pos += 2; + } + } +#undef NEED + if (invite_link_validate(&value)) return reject(error, error_size, "invalid invitation fields"); + value.nodeId = sc_derive_node_id_from_pubkey(value.pubkey); + *out = value; + if (error && error_size) error[0] = 0; + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "invite decoded channel=%llu node=%016llx addresses=%u", + (unsigned long long)value.channelId, (unsigned long long)value.nodeId, value.addrCount); + return 0; +} + +int invite_serialize_addrs(const struct InviteData* data, uint8_t* out, size_t capacity) { + if (!out) return reject(NULL, 0, "missing output buffer"); + if (invite_link_validate(data)) return -1; + uint8_t buf[INVITE_RAW_MAX]; size_t pos = 0; + buf[pos++] = data->reality.has; if (data->reality.has) { memcpy(buf + pos, data->reality.short_id, 8); pos += 8; memcpy(buf + pos, data->reality.server_pubkey, 32); pos += 32; memcpy(buf + pos, data->reality.version, 3); pos += 3; - size_t snlen = strlen(data->reality.server_name); - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1; - buf[pos++] = (uint8_t)snlen; - memcpy(buf + pos, data->reality.server_name, snlen); pos += snlen; + size_t size = strlen(data->reality.server_name); + buf[pos++] = (uint8_t)size; memcpy(buf + pos, data->reality.server_name, size); pos += size; } - for (int i = 0; i < (int)data->addrCount; i++) { + for (unsigned i = 0; i < data->addrCount; i++) { const struct InviteAddr* a = &data->addrs[i]; - int ip_len = (a->family == 6) ? 16 : 4; + size_t size = a->family == 6 ? 16 : 4; buf[pos++] = (uint8_t)a->family; buf[pos++] = a->socketId; buf[pos++] = a->proto; - memcpy(buf + pos, a->address, (size_t)ip_len); pos += ip_len; - buf[pos++] = (uint8_t)(a->port >> 8); buf[pos++] = (uint8_t)(a->port & 0xFF); + memcpy(buf + pos, a->address, size); pos += size; + buf[pos++] = (uint8_t)(a->port >> 8); buf[pos++] = (uint8_t)a->port; } - return (int)pos; + if (pos > capacity) return reject(NULL, 0, "address output buffer too small"); + memcpy(out, buf, pos); return (int)pos; } -int invite_link_encode(const struct InviteData* data, const char* password, char* out, size_t out_size) { - if (!data || !out) { - DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "invite_link_encode: missing input or output"); return -1; - } - static const uint8_t empty_key[INVITE_PUBKEY_SIZE] = {0}; - if (!memcmp(data->pubkey, empty_key, sizeof(empty_key))) { - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "invite_link_encode: empty public key"); return -1; - } - if (data->addrCount == 0) return -1; - uint8_t raw[4096]; size_t pos = 0; - raw[pos++] = INVITE_LINK_VERSION; - { - size_t plen = (password && password[0]) ? strlen(password) : 0; - if (plen > INVITE_PASS_MAX - 1) return -1; - raw[pos++] = (uint8_t)plen; - if (plen) { memcpy(raw + pos, password, plen); pos += plen; } - } - for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)((data->channelId >> (i * 8)) & 0xFF); - for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)((data->join_key >> (i * 8)) & 0xFF); - - raw[pos++] = data->reality.has ? 1 : 0; - if (data->reality.has) { - memcpy(raw + pos, data->reality.short_id, 8); pos += 8; - memcpy(raw + pos, data->reality.server_pubkey, 32); pos += 32; - memcpy(raw + pos, data->reality.version, 3); pos += 3; - size_t snlen = strlen(data->reality.server_name); - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1; - raw[pos++] = (uint8_t)snlen; - memcpy(raw + pos, data->reality.server_name, snlen); pos += snlen; - } - - uint8_t pk[32]; memcpy(pk, data->pubkey, 32); - - int ia = 0; - while (ia < (int)data->addrCount) { - int rem = (int)data->addrCount - ia; - int cnt = (rem > 4) ? 4 : rem; - uint8_t header = (uint8_t)((cnt - 1) & 0x03); - for (int j = 0; j < cnt; j++) if (data->addrs[ia + j].family == 6) header |= (uint8_t)(1 << (2 + j)); - raw[pos++] = header; memcpy(raw + pos, pk, 32); pos += 32; - for (int j = 0; j < cnt; j++) { - const struct InviteAddr* a = &data->addrs[ia + j]; - int ip_len = (a->family == 6) ? 16 : 4; +int invite_link_encode(const struct InviteData* data, const char* password, char* out, size_t capacity) { + if (!out) return reject(NULL, 0, "missing output buffer"); + if (invite_link_validate(data)) return -1; + size_t pass_len = password ? strnlen(password, INVITE_PASS_MAX) : 0; + if (pass_len >= INVITE_PASS_MAX) return reject(NULL, 0, "password too long"); + uint8_t raw[INVITE_RAW_MAX]; size_t pos = 0; + raw[pos++] = INVITE_LINK_VERSION; raw[pos++] = (uint8_t)pass_len; + if (pass_len) { memcpy(raw + pos, password, pass_len); pos += pass_len; } + for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)(data->channelId >> (8 * i)); + for (int i = 7; i >= 0; i--) raw[pos++] = (uint8_t)(data->join_key >> (8 * i)); + uint8_t addresses[INVITE_RAW_MAX]; + int address_len = invite_serialize_addrs(data, addresses, sizeof(addresses)); + if (address_len < 0) return -1; + size_t prefix_len = 1; + if (data->reality.has) prefix_len += 44 + strlen(data->reality.server_name); + memcpy(raw + pos, addresses, prefix_len); pos += prefix_len; + for (unsigned i = 0; i < data->addrCount;) { + unsigned count = data->addrCount - i; if (count > 4) count = 4; + uint8_t header = (uint8_t)(count - 1); + for (unsigned j = 0; j < count; j++) if (data->addrs[i + j].family == 6) header |= (uint8_t)(1u << (2 + j)); + raw[pos++] = header; memcpy(raw + pos, data->pubkey, 32); pos += 32; + for (unsigned j = 0; j < count; j++) { + const struct InviteAddr* a = &data->addrs[i++]; + size_t size = a->family == 6 ? 16 : 4; raw[pos++] = a->socketId; raw[pos++] = a->proto; - memcpy(raw + pos, a->address, (size_t)ip_len); pos += (size_t)ip_len; - raw[pos++] = (uint8_t)(a->port >> 8); raw[pos++] = (uint8_t)(a->port & 0xFF); + memcpy(raw + pos, a->address, size); pos += size; + raw[pos++] = (uint8_t)(a->port >> 8); raw[pos++] = (uint8_t)a->port; } - ia += cnt; } - - if (out_size < 12 + (pos * 4 + 2) / 3 + 1) return -1; - memcpy(out, INVITE_PREFIX, INVITE_PREFIX_LEN); - size_t opos = INVITE_PREFIX_LEN; - 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; - out[opos++] = base64_table[(val >> 18) & 0x3F]; - out[opos++] = base64_table[(val >> 12) & 0x3F]; - out[opos++] = (i + 1 < pos) ? base64_table[(val >> 6) & 0x3F] : '='; - out[opos++] = (i + 2 < pos) ? base64_table[val & 0x3F] : '='; - i += 3; - } - if (opos >= out_size) return -1; - out[opos] = '\0'; - return (int)(opos - INVITE_PREFIX_LEN); + size_t size = 4 * ((pos + 2) / 3); + if (capacity < 7 + size + 1) return reject(NULL, 0, "invitation output buffer too small"); + memcpy(out, INVITE_PREFIX, 7); EVP_EncodeBlock((unsigned char*)out + 7, raw, (int)pos); + return (int)size; } diff --git a/src/chat/invite_link.h b/src/chat/invite_link.h index 4cbfe603..fb884767 100644 --- a/src/chat/invite_link.h +++ b/src/chat/invite_link.h @@ -1,9 +1,10 @@ /* * invite_link.h — invite-ссылки utun:// для каналов * - * Версия 0x03: utun:// + base64(version | pass_len/password | channel_id | join_key | + * encode/decode используют только версию 0x03. Проверка общая для всех клиентов. + * Формат 0x03: utun:// + обычный base64 с padding(version | pass_len/password | channel_id | join_key | * reality_has/params | блоки pubkey/addrs). ID/port кодируются big-endian; nodeId выводится из pubkey. - * Общий формат с desktop Qt и Android Kotlin. Сам кодек не подключает узел и не регистрирует join_key. + * Сам кодек не подключает узел, не регистрирует join_key и не проверяет подпись/членство. * decode: 0/-1; encode: длина base64 без utun:// и NUL/-1; serialize_addrs: число байтов/-1. * Буферы предоставляет вызывающий; encode завершает строку NUL. */ @@ -14,6 +15,7 @@ #include #define INVITE_LINK_VERSION 0x03 +#define INVITE_LINK_MAX 1552 /* максимальная ссылка с NUL: 32 IPv6, REALITY и пароль */ #define INVITE_PUBKEY_SIZE 32 #define INVITE_ADDR_MAX 32 #define INVITE_PASS_MAX 128 @@ -28,39 +30,53 @@ extern "C" { #endif struct InviteAddr { - int family; - uint8_t address[16]; - uint8_t socketId; - uint8_t proto; - uint16_t port; + int family; /* 4 или 6, не AF_INET/AF_INET6 */ + uint8_t address[16]; /* сетевые байты IP; для IPv4 используются первые 4 */ + uint8_t socketId; /* ID сокета узла подключения */ + uint8_t proto; /* INVITE_PROTO_* */ + uint16_t port; /* значение порта в порядке байтов хоста */ }; /* Reality-параметры узла (node-global: одинаковы для всех reality-сокетов). */ struct InviteReality { uint8_t has; /* 1 = reality присутствует в ссылке */ - uint8_t short_id[8]; - uint8_t server_pubkey[32]; - uint8_t version[3]; - char server_name[INVITE_SNAME_MAX]; + uint8_t short_id[8]; /* идентификатор REALITY-сервера */ + uint8_t server_pubkey[32]; /* публичный ключ REALITY-сервера */ + uint8_t version[3]; /* версия REALITY */ + char server_name[INVITE_SNAME_MAX]; /* SNI, NUL-строка до 63 байт */ }; struct InviteData { - uint64_t channelId; + uint64_t channelId; /* числовой ID канала */ uint64_t join_key; /* 64-битный ключ входа (генерируется инвайтером, хранится на connection-узле 10 мин) */ - uint8_t pubkey[INVITE_PUBKEY_SIZE]; - uint64_t nodeId; - uint8_t addrCount; - struct InviteAddr addrs[INVITE_ADDR_MAX]; - char password[INVITE_PASS_MAX]; - uint8_t password_len; - struct InviteReality reality; + uint8_t pubkey[INVITE_PUBKEY_SIZE]; /* X25519 узла подключения */ + uint64_t nodeId; /* decode выводит из первого pubkey; encode не использует */ + uint8_t addrCount; /* число используемых элементов addrs, не больше INVITE_ADDR_MAX */ + struct InviteAddr addrs[INVITE_ADDR_MAX]; /* адреса узла подключения */ + char password[INVITE_PASS_MAX]; /* декодированный пароль, NUL-строка */ + uint8_t password_len; /* длина декодированного пароля без NUL */ + struct InviteReality reality; /* параметры при reality.has != 0 */ }; +/* Проверить поля и адреса перед encode/использованием; 0/-1, без побочных действий. */ +int invite_link_validate(const struct InviteData* data); + +/* Разобрать link_len байт ссылки; out принадлежит вызывающему и не изменяется при ошибке. + * 0/-1; error_buf необязателен, при ошибке получает NUL-строку, если error_buf_size > 0. + * Успех гарантирует обязательные поля и согласованные блоки; nodeId вычисляется из pubkey. */ int invite_link_decode(const char* link, size_t link_len, struct InviteData* out, char* error_buf, size_t error_buf_size); +/* Записать полную ссылку с NUL. password — отдельная NUL-строка до 127 байт либо NULL; + * data->password/password_len/nodeId игнорируются. Требуются непустой pubkey и 1..INVITE_ADDR_MAX адресов. + * Возврат: длина base64 без префикса utun:// и NUL либо -1; при ошибке out не использовать. */ int invite_link_encode(const struct InviteData* data, const char* password, char* out, size_t out_size); +/* Подготовить бинарное представление адресов, без base64 и без счётчика: + * [reality_has:1][при наличии: short_id:8,pubkey:32,version:3,sni_len:1,sni] + * затем addrCount записей [family:1,socketId:1,proto:1,IP:4/16,port:2BE]. + * data должен содержать не больше INVITE_ADDR_MAX адресов и корректные NUL-строки. + * Возврат: записанные байты либо -1 (нет аргумента/буфер мал); данные копируются в buf. */ int invite_serialize_addrs(const struct InviteData* data, uint8_t* buf, size_t buf_size); #ifdef __cplusplus diff --git a/src/chat/member_sync.c b/src/chat/member_sync.c index 8326807a..9f0bc6b0 100644 --- a/src/chat/member_sync.c +++ b/src/chat/member_sync.c @@ -733,7 +733,7 @@ static int member_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, return (changed ? MS_APPLY_CHANGED : 0) | (stale ? MS_APPLY_STALE : 0); } -static void member_committed(struct UTUN_INSTANCE* inst, const char* ns, uint64_t node_id) { +void member_sync_record_committed(struct UTUN_INSTANCE* inst, const char* ns, uint64_t node_id) { merkle_sync_changed(inst, MT_DATA_MEMBERS, ns); struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, strtoull(ns, NULL, 10)) : NULL; struct ETCP_CONN* conn = instance_find_conn(inst, node_id); @@ -764,7 +764,7 @@ int member_sync_apply_record(struct UTUN_INSTANCE* inst, const char* ch_id, DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "%s: record cleanup: %s", MS_ID, sqlite3_errmsg(db)); return -1; } - if (own && rc >= 0 && (rc & MS_APPLY_CHANGED)) member_committed(inst, ch_id, m->node_id); + if (own && rc >= 0 && (rc & MS_APPLY_CHANGED)) member_sync_record_committed(inst, ch_id, m->node_id); return rc; } @@ -1010,7 +1010,7 @@ static int _member_apply_items(void* ctx, const char* ns, uint64_t from_peer, co struct member_wire w; size_t used; if (member_decode(data + off, len - off, &w, &used) < 0) { rc = MT_ERR_DATA; break; } if (changed[i]) { - member_committed(inst, ns, w.rec.node_id); + member_sync_record_committed(inst, ns, w.rec.node_id); if (!inst->topo_groups || !topo_groups_find(inst->topo_groups, strtoull(ns, NULL, 10))) _fire_props_changed(inst, w.rec.node_id, w.tags[0] ? w.tags : NULL, ns); _fire_apply_cbk(inst, ns, w.rec.node_id, w.rec.x25519, w.rec.signed_by, w.rec.signature); @@ -1109,10 +1109,16 @@ fail: } /* Подписаться на BGP node-события chat-группы (BGP → member_sync → node_props_changed). */ -void member_sync_subscribe_group(struct UTUN_INSTANCE* inst, struct TOPO_GROUP* group) { - if (!inst || !group || group->group_type != TOPO_GROUP_TYPE_CHAT) return; +int member_sync_subscribe_group(struct UTUN_INSTANCE* inst, struct TOPO_GROUP* group) { + if (!inst || !group || group->group_type != TOPO_GROUP_TYPE_CHAT) { + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "invalid group subscription"); return -1; + } + for (struct topo_node_cbk_entry* cb = group->node_cbks; cb; cb = cb->next) + if (cb->fn == _ms_on_bgp_node && cb->arg == inst) return 0; topo_group_add_node_cbk(group, _ms_on_bgp_node, inst); - DEBUG_DEBUG(DEBUG_CATEGORY_MEMBER_SYNC, "%s: subscribe_group ch=%s", MS_ID, group->channel_id); + for (struct topo_node_cbk_entry* cb = group->node_cbks; cb; cb = cb->next) + if (cb->fn == _ms_on_bgp_node && cb->arg == inst) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_MEMBER_SYNC, "group subscription allocation failed ch=%s", group->channel_id); return -1; } int member_sync_init(struct UTUN_INSTANCE* inst) { @@ -1128,7 +1134,7 @@ int member_sync_init(struct UTUN_INSTANCE* inst) { while (ge) { struct TOPO_GROUP* g = (struct TOPO_GROUP*)ge; if (g->group_type == TOPO_GROUP_TYPE_CHAT && g->channel_id[0]) - member_sync_subscribe_group(inst, g); + if (member_sync_subscribe_group(inst, g) < 0) { member_sync_destroy(inst); return -1; } ge = ge->next; } } diff --git a/src/chat/member_sync.h b/src/chat/member_sync.h index 63384a4d..0da4adba 100644 --- a/src/chat/member_sync.h +++ b/src/chat/member_sync.h @@ -98,7 +98,8 @@ void member_sync_destroy(struct UTUN_INSTANCE* inst); /* Подписаться на BGP node-события chat-группы (BGP → member_sync → node_props_changed). Вызывается при создании канала (chat_core_ensure_channel_ready). */ -void member_sync_subscribe_group(struct UTUN_INSTANCE* inst, struct TOPO_GROUP* group); +/* Идемпотентно подписать CHAT-группу; 0/-1, отказ регистрации явно возвращается. */ +int member_sync_subscribe_group(struct UTUN_INSTANCE* inst, struct TOPO_GROUP* group); /* * Запустить синхронизацию мемберов канала ch_id с пиром peer. @@ -212,6 +213,9 @@ typedef void (*node_props_changed_fn)(uint64_t node_id, const char* adm_tags, co int member_sync_add_props_cbk(struct UTUN_INSTANCE* inst, node_props_changed_fn fn, void* arg); void member_sync_remove_props_cbk(struct UTUN_INSTANCE* inst, node_props_changed_fn fn, void* arg); +/* Опубликовать изменения после внешнего commit; uasync, БД уже вне транзакции. */ +void member_sync_record_committed(struct UTUN_INSTANCE* inst, const char* channel, uint64_t node_id); + #ifdef __cplusplus } #endif diff --git a/src/utun_instance.c b/src/utun_instance.c index 02b272e4..cffcd397 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -895,6 +895,7 @@ fail: void chat_service_stop(struct UTUN_INSTANCE* instance) { if (!instance) return; instance->chat_started = 0; + chat_sync_quiesce(instance); chat_local_stop(instance); radio_call_destroy(instance); chat_headless_control_destroy(instance); diff --git a/tests/Makefile.am b/tests/Makefile.am index 6e14280c..7e33188c 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -70,6 +70,8 @@ check_PROGRAMS = \ test_intensive_memory_pool \ test_ll_queue \ test_invite_group_create \ + test_invite_codec \ + test_chat_join_faults \ test_nat_transport \ test_conn_mgr_already_connected \ test_udp_proxy \ @@ -1240,3 +1242,14 @@ test_proxy_lifecycle_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_proxy_lifecycle_LDFLAGS = $(async_regression_ldflags) -Wl,--wrap=socket -Wl,--wrap=bind -Wl,--wrap=sendto \ -Wl,--wrap=socket_set_nonblocking -Wl,--wrap=uasync_add_socket_t -Wl,--wrap=uasync_set_timeout endif + +# Общий кодек без запуска сети; атомарность всех выходов и границы формата. +test_invite_codec_SOURCES = test_invite_codec.c +test_invite_codec_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib +test_invite_codec_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + +# Настоящий controller/NCD/SQLite; транспорт и отдельные отказы перехвачены. +test_chat_join_faults_SOURCES = test_chat_join_faults.c +test_chat_join_faults_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/transport_layer -I$(top_srcdir)/src/routing_layer -I$(top_srcdir)/lib +test_chat_join_faults_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_chat_join_faults_LDFLAGS = -Wl,--wrap=node_conn_direct_open_node -Wl,--wrap=topo_groups_create_group -Wl,--wrap=topo_group_add_node_cbk -Wl,--wrap=etcp_send -Wl,--wrap=uasync_set_timeout -Wl,--wrap=u_malloc_impl -Wl,--wrap=u_calloc_impl diff --git a/tests/stream_netns/node.c b/tests/stream_netns/node.c index 2a2c0c78..7b38b906 100644 --- a/tests/stream_netns/node.c +++ b/tests/stream_netns/node.c @@ -449,8 +449,7 @@ static void command(struct lab *lab, const char *json) { int size = invite_serialize_addrs(&invite, addresses, sizeof(addresses)); if (size <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "lab: invite has no endpoint"); reply(id, -1); return; } lab->group = invite.channelId; - chat_sync_connect_from_invite(lab->inst, invite.channelId, invite.nodeId, invite.pubkey, addresses, - invite.addrCount, size, invite.password_len ? invite.password : NULL, invite.join_key); + if (chat_sync_join_link(lab->inst, 1, link, strlen(link))) { reply(id, -1); return; } reply(id, 0); return; } int publish = !strcmp(operation, "publish"); diff --git a/tests/test_chat_join_e2e.c b/tests/test_chat_join_e2e.c index 0670b25c..fc176fa4 100644 --- a/tests/test_chat_join_e2e.c +++ b/tests/test_chat_join_e2e.c @@ -661,8 +661,8 @@ static int child_main(const char* role, const char* dir, int invalid_key) { if (addrs_len <= 0) { fprintf(stderr, "J: serialize addrs failed\n"); goto out; } uint64_t key = invalid_key ? (d.join_key ^ 0xDEADBEEFCAFEBABEULL) : d.join_key; - chat_sync_connect_from_invite(inst, d.channelId, d.nodeId, d.pubkey, - addrs_buf, d.addrCount, addrs_len, d.password_len ? d.password : NULL, key); + d.join_key = key; + if (invite_link_encode(&d, d.password, link, sizeof(link)) < 0 || chat_sync_join_link(inst, 1, link, strlen(link))) goto out; if (invalid_key) { rc = wait_not_member(inst, g_sh.nid[IDX_J], 200) ? 0 : 1; diff --git a/tests/test_chat_join_faults.c b/tests/test_chat_join_faults.c new file mode 100644 index 00000000..28c712d6 --- /dev/null +++ b/tests/test_chat_join_faults.c @@ -0,0 +1,305 @@ +/* Реальный chat_sync/NCD/SQLite с управляемым транспортом: отказы, корреляция и teardown. */ +#include "utun_instance.h" +#include "chat/chat_core.h" +#include "chat/chat_sync.h" +#include "chat/chat_join.h" +#include "chat/db_sync.h" +#include "routing_layer/topo_group.h" +#include "chat/chat_event.h" +#include "chat/chat_join_port.h" +#include "chat/invite_link.h" +#include "transport_layer/etcp.h" +#include "transport_layer/etcp_api.h" +#include "transport_layer/etcp_connections.h" +#include "transport_layer/node_conn_direct.h" +#include "routing_layer/topo_node.h" +#include "../lib/mem.h" +#include +#include +#include +#include +#define CHECK(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "join fault line=%d: %s", __LINE__, #x); exit(1); } } while (0) +static struct UTUN_INSTANCE* inst; +static struct UASYNC* ua; +static struct ETCP_CONN* conn; +static char invitation[INVITE_LINK_MAX]; +static uint8_t request_wire[512], response_wire[8192], member_wire[8192], ready_wire[32]; +static size_t response_size, member_size, ready_size; +static struct UTUN_INSTANCE* server; +static struct ETCP_CONN* server_conn; +static uint64_t channel; +static size_t request_size; +static unsigned allocations, fail_allocation, events[512]; +static int results[512], fail_send, reenter, reenter_result; +static const char* fail_timer; +static int fail_runtime, fail_ncd, nested_up; +static unsigned info_sends; +static pthread_mutex_t race_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t race_condition = PTHREAD_COND_INITIALIZER; +static int race_gate, race_waiting, race_release, race_result; +static uint64_t peer; + +void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); +void* __real_u_malloc_impl(uint32_t, const char*); +static int allocation_failed(const char* site) { + if (fail_runtime == 1 && strstr(site, "db_sync.c")) { fail_runtime = 0; return 1; } + if (!strstr(site, "chat_sync.c") && !strstr(site, "chat_join_port.c")) return 0; + allocations++; + return fail_allocation && allocations == fail_allocation; +} +void* __wrap_u_calloc_impl(uint32_t count, uint32_t size, const char* site) { + if (allocation_failed(site)) return NULL; + void* data = __real_u_calloc_impl(count, size, site); + if (race_gate && strstr(site, "chat_join_port.c")) { + pthread_mutex_lock(&race_lock); race_waiting = 1; pthread_cond_signal(&race_condition); + while (!race_release) pthread_cond_wait(&race_condition, &race_lock); + pthread_mutex_unlock(&race_lock); + } + return data; +} +void* __wrap_u_malloc_impl(uint32_t size, const char* site) { + if (allocation_failed(site)) return NULL; + return __real_u_malloc_impl(size, site); +} +void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*); +void* __wrap_uasync_set_timeout(struct UASYNC* loop, int delay, void* arg, timeout_callback_t fn, const char* name) { + if (fail_timer && !strcmp(name, fail_timer)) return NULL; + return __real_uasync_set_timeout(loop, delay, arg, fn, name); +} +struct TOPO_GROUP* __real_topo_groups_create_group(struct TOPO_GROUPS*, uint64_t, uint8_t, const char*); +struct TOPO_GROUP* __wrap_topo_groups_create_group(struct TOPO_GROUPS* groups, uint64_t id, uint8_t type, const char* name) { + if (fail_runtime == 2) { fail_runtime = 0; return NULL; } + return __real_topo_groups_create_group(groups, id, type, name); +} +void __real_topo_group_add_node_cbk(struct TOPO_GROUP*, topo_node_event_fn, void*); +void __wrap_topo_group_add_node_cbk(struct TOPO_GROUP* group, topo_node_event_fn fn, void* arg) { + if (fail_runtime == 3) { fail_runtime = 0; return; } + __real_topo_group_add_node_cbk(group, fn, arg); +} +int __real_node_conn_direct_open_node(struct UTUN_INSTANCE*, uint64_t, ncd_callback, void*, struct NODE_CONN_DIRECT**, struct TOPO_NODE*, struct ETCP_SOCKET*); +int __wrap_node_conn_direct_open_node(struct UTUN_INSTANCE* owner, uint64_t id, ncd_callback cb, void* arg, + struct NODE_CONN_DIRECT** handle, struct TOPO_NODE* node, struct ETCP_SOCKET* socket) { + if (fail_ncd) { *handle = NULL; return NCD_ERR; } + if (nested_up && owner == inst) { nested_up = 0; etcp_fire_conn_status(conn, ETCP_CONN_STATUS_UP); } + return __real_node_conn_direct_open_node(owner, id, cb, arg, handle, node, socket); +} +int __real_etcp_send(struct ETCP_CONN*, struct ll_entry*); +int __wrap_etcp_send(struct ETCP_CONN* c, struct ll_entry* entry) { + if (entry->len >= 18 && entry->dgram[0] == ETCP_ID_CHAT_SYNC) { + if (entry->dgram[9] == CS_MSG_JOIN_INFO_REQ) { + if (c->instance == inst) info_sends++; + CHECK(entry->len <= sizeof(request_wire)); request_size = entry->len; + memcpy(request_wire, entry->dgram, request_size); + if (fail_send) return -1; + } + if (entry->dgram[9] == CS_MSG_JOIN_INFO_RESP) { response_size = entry->len; CHECK(response_size <= sizeof(response_wire)); memcpy(response_wire, entry->dgram, response_size); } + if (entry->dgram[9] == CS_MSG_JOIN_REQUEST) { member_size = entry->len; CHECK(member_size <= sizeof(member_wire)); memcpy(member_wire, entry->dgram, member_size); } + if (entry->dgram[9] == CS_MSG_JOIN_READY) { ready_size = entry->len; CHECK(ready_size <= sizeof(ready_wire)); memcpy(ready_wire, entry->dgram, ready_size); } + queue_dgram_free(entry); queue_entry_free(entry); return 0; + } + return __real_etcp_send(c, entry); +} +static void event(struct UTUN_INSTANCE* owner, int type, const uint8_t* data, int len) { + if (type != CHAT_EVT_CONNECT_RESULT) return; + CHECK(owner == inst && len == 28); + uint64_t request; int32_t result; + memcpy(&request, data + 20, 8); memcpy(&result, data + 8, 4); + CHECK(request < 512); events[request]++; results[request] = result; + if (reenter && request == 30) { + reenter = 0; reenter_result = chat_sync_join_link(inst, 31, invitation, strlen(invitation)); + } +} +static void deliver_error(const uint8_t token[8]) { + struct ll_entry* entry = ll_alloc_lldgram(18); CHECK(entry); + entry->len = 18; entry->dgram[0] = ETCP_ID_CHAT_SYNC; + uint64_t group = htobe64(channel); memcpy(entry->dgram + 1, &group, 8); + entry->dgram[9] = CS_MSG_ERROR; memcpy(entry->dgram + 10, token, 8); + inst->api_bindings.callbacks[ETCP_ID_CHAT_SYNC](conn, entry); +} +static void deliver(struct UTUN_INSTANCE* target, struct ETCP_CONN* connection, const uint8_t* bytes, size_t size) { + struct ll_entry* entry = ll_alloc_lldgram(size); CHECK(entry); entry->len = size; memcpy(entry->dgram, bytes, size); + target->api_bindings.callbacks[ETCP_ID_CHAT_SYNC](connection, entry); +} +static void description(void) { + response_size = 0; deliver(server, server_conn, request_wire, request_size); CHECK(response_size > 200); +} +static int channels(void) { + sqlite3_stmt* st = NULL; CHECK(sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT count(*) FROM channels", -1, &st, NULL) == SQLITE_OK); + CHECK(sqlite3_step(st) == SQLITE_ROW); int value = sqlite3_column_int(st, 0); sqlite3_finalize(st); return value; +} +static void* producer(void* arg) { + struct chat_join_port* port = arg; + CHECK(!chat_join_port_submit(port, 60, invitation, strlen(invitation))); + return NULL; +} +static void* stopped_producer(void* arg) { + race_result = chat_join_port_submit(arg, 71, invitation, strlen(invitation)); return NULL; +} +static void stop_in_batch(void* arg) { chat_service_stop(arg); } +int main(void) { + size_t before = u_get_allocated_count(); + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); + ua = uasync_create(); CHECK(ua); + inst = utun_instance_create_from_str(ua, "[global]\nauto_sockets=no\n[ntp]\nenabled=no\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"); + CHECK(inst && !utun_core_start(inst) && !chat_service_start(inst)); + chat_event_set_handler(inst, event); + server = utun_instance_create_from_str(ua, "[global]\nauto_sockets=no\n[ntp]\nenabled=no\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"); + CHECK(server && !utun_core_start(server) && !chat_service_start(server)); + chat_core_create_channel_auto(server, "fault channel"); + CHECK(server->topo_groups->group_list->head); + channel = ((struct TOPO_GROUP*)server->topo_groups->group_list->head)->group_id; + CHECK(!chat_join_store_local_key(server, channel, 123)); + struct InviteData invite = { .channelId = channel, .join_key = 123, .addrCount = 4 }; + memcpy(invite.pubkey, server->my_keys.public_key, 32); peer = server->node_id; + for (unsigned i = 0; i < invite.addrCount; i++) { + invite.addrs[i].family = i & 1 ? 6 : 4; invite.addrs[i].port = 12345; + invite.addrs[i].proto = 1; invite.addrs[i].address[0] = 127; invite.addrs[i].address[3] = 1; + } + invite.reality.has = 1; memcpy(invite.reality.server_pubkey, server->my_keys.public_key, 32); + snprintf(invite.reality.server_name, sizeof(invite.reality.server_name), "example.org"); invite.addrs[3].proto = 6; + CHECK(invite_link_encode(&invite, NULL, invitation, sizeof(invitation)) > 0); + conn = etcp_connection_create(inst, "join fault transport"); CHECK(conn); + conn->peer_node_id = peer; CHECK(!etcp_conn_index_peer(conn)); + CHECK(sc_init_ctx(&conn->crypto_ctx, &inst->my_keys) == SC_OK); + CHECK(sc_set_peer_public_key(&conn->crypto_ctx, invite.pubkey, SC_PEER_PUBKEY_BIN) == SC_OK); + conn->links_up = 1; etcp_conn_ready(conn); conn->initialized = 1; + struct NODE_CONN_DIRECT* other_owner = NULL; + CHECK(node_conn_direct_open(inst, peer, NULL, NULL, &other_owner, NULL) != NCD_ERR); + uasync_drain_immediate(ua); + server_conn = etcp_connection_create(server, "join server fixture"); CHECK(server_conn); + server_conn->peer_node_id = inst->node_id; CHECK(!etcp_conn_index_peer(server_conn)); + CHECK(sc_init_ctx(&server_conn->crypto_ctx, &server->my_keys) == SC_OK); + CHECK(sc_set_peer_public_key(&server_conn->crypto_ctx, inst->my_keys.public_key, SC_PEER_PUBKEY_BIN) == SC_OK); + server_conn->links_up = 1; etcp_conn_ready(server_conn); server_conn->initialized = 1; + CHECK(!channels()); + CHECK(chat_sync_join_link(inst, 1, "utun://", 7) < 0 && !channels() && !events[1]); + + CHECK(!chat_sync_join_link(inst, 10, invitation, strlen(invitation)) && request_size > 18); + uint8_t old[8]; memcpy(old, request_wire + 10, 8); + CHECK(chat_sync_join_link(inst, 11, invitation, strlen(invitation)) == CHAT_CONNECT_BUSY && !events[11]); + chat_sync_cancel_join(inst, 999); CHECK(!events[10]); + chat_sync_cancel_join(inst, 10); CHECK(events[10] == 1 && results[10] == CHAT_CONNECT_CANCELLED); + CHECK(!chat_sync_join_link(inst, 12, invitation, strlen(invitation))); + uint8_t current[8]; memcpy(current, request_wire + 10, 8); CHECK(memcmp(old, current, 8)); + deliver_error(old); CHECK(!events[12]); + deliver_error(current); CHECK(events[12] == 1 && results[12] == CHAT_CONNECT_ERROR); + deliver_error(current); CHECK(events[12] == 1); + CHECK(node_conn_direct_get_conn(other_owner) == conn && !conn->close_requested); + CHECK(!channels()); + + reenter = 1; CHECK(!chat_sync_join_link(inst, 30, invitation, strlen(invitation))); + chat_sync_cancel_join(inst, 30); CHECK(!reenter_result && events[30] == 1); + chat_sync_cancel_join(inst, 31); CHECK(events[31] == 1); + + /* Сначала измерить все реальные точки выделения этого пути, затем отказать в каждой. */ + allocations = 0; CHECK(!chat_sync_join_link(inst, 40, invitation, strlen(invitation))); + unsigned total = allocations; CHECK(total >= 5); chat_sync_cancel_join(inst, 40); + for (unsigned i = 1; i <= total; i++) { + allocations = 0; fail_allocation = i; + int rc = chat_sync_join_link(inst, 41, invitation, strlen(invitation)); + fail_allocation = 0; + CHECK(rc < 0 && !events[41] && !channels()); + CHECK(node_conn_direct_get_conn(other_owner) == conn && !conn->close_requested); + CHECK(!chat_sync_join_link(inst, 42, invitation, strlen(invitation))); chat_sync_cancel_join(inst, 42); + } + for (unsigned i = 0; i < 2; i++) { + fail_timer = i ? "cs_info_req" : "join_transport_timeout"; + CHECK(chat_sync_join_link(inst, 43, invitation, strlen(invitation)) < 0 && !events[43]); fail_timer = NULL; + CHECK(!chat_sync_join_link(inst, 44, invitation, strlen(invitation))); chat_sync_cancel_join(inst, 44); + } + fail_send = 1; CHECK(chat_sync_join_link(inst, 45, invitation, strlen(invitation)) < 0 && !events[45]); fail_send = 0; + CHECK(!chat_sync_join_link(inst, 46, invitation, strlen(invitation))); chat_sync_cancel_join(inst, 46); + + fail_ncd = 1; CHECK(chat_sync_join_link(inst, 47, invitation, strlen(invitation)) < 0 && !events[47]); fail_ncd = 0; + CHECK(node_conn_direct_get_conn(other_owner) == conn && !conn->close_requested); + unsigned previous_sends = info_sends; nested_up = 1; + CHECK(!chat_sync_join_link(inst, 48, invitation, strlen(invitation)) && !nested_up && info_sends == previous_sends + 1); + chat_sync_cancel_join(inst, 48); CHECK(events[48] == 1); + + /* Сервер обслуживает чужой JOIN, пока сам ждёт завершения локального JOIN. */ + struct InviteData reverse = invite; + memcpy(reverse.pubkey, inst->my_keys.public_key, 32); + char reverse_link[INVITE_LINK_MAX]; CHECK(invite_link_encode(&reverse, NULL, reverse_link, sizeof(reverse_link)) > 0); + CHECK(!chat_sync_join_link(server, 90, reverse_link, strlen(reverse_link))); + CHECK(!sqlite3_exec(inst->topo_sqlite_db, "CREATE TEMP TRIGGER reject_join BEFORE INSERT ON channels " + "BEGIN SELECT RAISE(ABORT,'join fault'); END", NULL, NULL, NULL)); + CHECK(!chat_sync_join_link(inst, 80, invitation, strlen(invitation))); description(); + deliver(inst, conn, response_wire, response_size); + CHECK(events[80] == 1 && results[80] == CHAT_CONNECT_ERROR && !channels() && sqlite3_get_autocommit(inst->topo_sqlite_db)); + CHECK(!sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER reject_join", NULL, NULL, NULL)); + CHECK(!chat_sync_join_link(inst, 81, invitation, strlen(invitation))); description(); + size_t signature_offset = 19 + response_wire[18] + 8 + 32 + 32 + 64 + 8 + 32 + 32 + 64 + 8; + CHECK(signature_offset + 64 < response_size); response_wire[signature_offset] ^= 1; + deliver(inst, conn, response_wire, response_size); + CHECK(events[81] == 1 && results[81] == CHAT_CONNECT_ERROR && !channels() && sqlite3_get_autocommit(inst->topo_sqlite_db)); + CHECK(!topo_groups_find(inst->topo_groups, channel)); + for (unsigned failure = 1; failure <= 3; failure++) { + uint64_t request = 85 + failure; + CHECK(!chat_sync_join_link(inst, request, invitation, strlen(invitation))); description(); + fail_runtime = (int)failure; deliver(inst, conn, response_wire, response_size); CHECK(!fail_runtime); + CHECK(events[request] == 1 && results[request] == CHAT_CONNECT_ERROR && channels() == 1); + CHECK(!topo_groups_find(inst->topo_groups, channel) && !db_sync_instance_find(inst, channel)); + } + CHECK(!chat_sync_join_link(inst, 82, invitation, strlen(invitation))); description(); + fail_timer = "cs_join"; deliver(inst, conn, response_wire, response_size); fail_timer = NULL; + CHECK(events[82] == 1 && results[82] == CHAT_CONNECT_ERROR && channels() == 1); + CHECK(!chat_sync_join_link(inst, 83, invitation, strlen(invitation))); description(); + member_size = 0; deliver(inst, conn, response_wire, response_size); CHECK(member_size > 200 && !events[83]); + ready_size = 0; deliver(server, server_conn, member_wire, member_size); CHECK(ready_size == 26); + deliver(inst, conn, ready_wire, ready_size); CHECK(events[83] == 1 && !results[83]); + deliver(inst, conn, ready_wire, ready_size); CHECK(events[83] == 1); + uint8_t previous_ready[32]; memcpy(previous_ready, ready_wire, ready_size); + for (unsigned failure = 0; failure < 2; failure++) { + uint64_t request = 84 + failure; + CHECK(!chat_sync_join_link(inst, request, invitation, strlen(invitation))); description(); + deliver(inst, conn, response_wire, response_size); CHECK(!events[request]); + deliver(inst, conn, previous_ready, 26); CHECK(!events[request]); + deliver(server, server_conn, member_wire, member_size); CHECK(ready_size == 26); + if (failure) { memset(ready_wire + 18, 0, 8); deliver(inst, conn, ready_wire, 26); } + else deliver(inst, conn, ready_wire, 25); + CHECK(events[request] == 1 && results[request] == CHAT_CONNECT_ERROR); + } + chat_sync_cancel_join(server, 90); + + struct chat_join_port* port = chat_sync_join_port(inst); CHECK(port); + allocations = 0; fail_allocation = 1; + CHECK(chat_join_port_submit(port, 50, invitation, strlen(invitation)) < 0 && !events[50]); fail_allocation = 0; + pthread_t thread; CHECK(!pthread_create(&thread, NULL, producer, port)); CHECK(!pthread_join(thread, NULL)); + CHECK(chat_join_port_submit(port, 60, invitation, strlen(invitation)) == CHAT_CONNECT_BUSY); + chat_service_stop(inst); CHECK(events[60] == 1 && results[60] == CHAT_CONNECT_CANCELLED); + CHECK(chat_join_port_submit(port, 61, invitation, strlen(invitation)) == CHAT_CONNECT_CANCELLED); + CHECK(!chat_service_start(inst)); CHECK(chat_join_port_submit(port, 62, invitation, strlen(invitation)) == CHAT_CONNECT_CANCELLED); + chat_join_port_release(port); + + port = chat_sync_join_port(inst); CHECK(port); + CHECK(!chat_join_port_submit(port, 65, invitation, strlen(invitation))); uasync_poll(ua, 0); + CHECK(!events[65] && chat_join_port_submit(port, 65, invitation, strlen(invitation)) == CHAT_CONNECT_BUSY); + CHECK(!chat_join_port_submit(port, 66, invitation, strlen(invitation))); uasync_poll(ua, 0); + CHECK(events[66] == 1 && results[66] == CHAT_CONNECT_BUSY && !events[65]); + chat_sync_cancel_join(inst, 65); CHECK(events[65] == 1 && results[65] == CHAT_CONNECT_CANCELLED); + /* Детерминированная гонка: stop между копированием аргументов producer и публикацией post. */ + race_gate = 1; CHECK(!pthread_create(&thread, NULL, stopped_producer, port)); + pthread_mutex_lock(&race_lock); + while (!race_waiting) pthread_cond_wait(&race_condition, &race_lock); + pthread_mutex_unlock(&race_lock); + chat_service_stop(inst); + pthread_mutex_lock(&race_lock); race_release = 1; pthread_cond_signal(&race_condition); pthread_mutex_unlock(&race_lock); + CHECK(!pthread_join(thread, NULL)); race_gate = 0; + CHECK(race_result == CHAT_CONNECT_CANCELLED && !events[71]); chat_join_port_release(port); + CHECK(!chat_service_start(inst)); + + /* close после извлечения списка post: отмена не находит task, пустой callback обязан освободить его. */ + port = chat_sync_join_port(inst); CHECK(port); + struct posted_task* stop = u_calloc(1, sizeof(*stop)); CHECK(stop); + stop->callback = stop_in_batch; stop->arg = inst; uasync_post_reserved(ua, stop); + CHECK(!chat_join_port_submit(port, 70, invitation, strlen(invitation))); + uasync_poll(ua, 0); CHECK(events[70] == 1 && results[70] == CHAT_CONNECT_CANCELLED); + chat_join_port_release(port); + node_conn_direct_close(other_owner); + utun_instance_destroy(server); utun_instance_destroy(inst); uasync_poll(ua, 0); uasync_destroy(ua, 0); + CHECK(u_get_allocated_count() == before); + printf("join faults: %u allocation failures, deadlines, send, stale replies, reentry, ownership, thread and restart PASS\n", total); + return 0; +} diff --git a/tests/test_chat_sync_schedule.c b/tests/test_chat_sync_schedule.c index 45650859..2f3c656d 100644 --- a/tests/test_chat_sync_schedule.c +++ b/tests/test_chat_sync_schedule.c @@ -157,10 +157,13 @@ static void test_sleep_and_cancel(void) { static void test_join_existing_transport(void) { struct fixture f; setup(&f); + assert(cs_pending_add(f.inst->chat_sync, f.peers[0]->peer_node_id, f.channel[0], f.peers[0], 1) == 0); cs_send_join_ready(f.inst->chat_sync, f.channel[0], f.peers[0]->peer_node_id); assert(!start_count); run_ms(&f, 1100); assert(start_count == 1 && starts[0].peer == f.peers[0]->peer_node_id && starts[0].channel == f.gid[0]); start_count = 0; + assert(cs_join_reserve(f.inst->chat_sync, f.gid[0], f.peers[1]->peer_node_id, 0, 0) == 0); + f.inst->chat_sync->join.phase = 3; cs_handle_join_ready(f.inst->chat_sync, f.peers[1]->peer_node_id, f.channel[0], (uint8_t*)&f.gid[0], 8); run_ms(&f, 1100); assert(start_count == 1 && starts[0].peer == f.peers[1]->peer_node_id && starts[0].channel == f.gid[0]); diff --git a/tests/test_dm_invite.c b/tests/test_dm_invite.c index 2f9937b1..2cd9ab23 100644 --- a/tests/test_dm_invite.c +++ b/tests/test_dm_invite.c @@ -58,7 +58,7 @@ static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int memcpy(&peer, data + 12, 8); memcpy(&conv, data + 20, 8); if (id >= 16 || peer != nodes[1]->node_id || conv != dm_derive_conv_id(nodes[0]->node_id, peer)) abort(); calls[id]++; results[id] = result; - } else if (inst == nodes[1] && type == CHAT_EVT_CONNECT_RESULT && len == 20) { + } else if (inst == nodes[1] && type == CHAT_EVT_CONNECT_RESULT && len == 28) { uint64_t channel; int32_t result; memcpy(&result, data + 8, 4); memcpy(&channel, data + 12, 8); if (channel == joining_channel) joined = result == 0 ? 1 : -1; @@ -109,8 +109,7 @@ static int join_link(struct UASYNC* ua, const char* link) { int bytes = invite_serialize_addrs(&invite, addresses, sizeof(addresses)); if (bytes < 0) return -1; joining_channel = invite.channelId; joined = 0; - chat_sync_connect_from_invite(nodes[1], invite.channelId, invite.nodeId, invite.pubkey, - addresses, invite.addrCount, bytes, NULL, invite.join_key); + if (chat_sync_join_link(nodes[1], 1, link, strlen(link))) return -1; uint64_t deadline = get_time_tb() + 200000; while (!joined && get_time_tb() < deadline) test_clock_poll(ua, 100); if (joined != 1) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "invite test: JOIN failed ch=%llu result=%d", diff --git a/tests/test_invite_codec.c b/tests/test_invite_codec.c new file mode 100644 index 00000000..2ab4c4e0 --- /dev/null +++ b/tests/test_invite_codec.c @@ -0,0 +1,101 @@ +/* Контракт кодека: полные roundtrip, точные буферы и атомарные выходы при отказе. */ +#include "chat/invite_link.h" +#include "transport_layer/secure_channel.h" +#include "../lib/debug_config.h" +#include +#include +#include +#include +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "codec line=%d: %s\n", __LINE__, #x); exit(1); } } while (0) +static unsigned cases; +static void rejected(const char* link, size_t len) { + struct InviteData out, before; memset(&out, 0xa5, sizeof(out)); before = out; + char error[3] = {1, 1, 1}; + CHECK(invite_link_decode(link, len, &out, error, sizeof(error)) < 0); + CHECK(!memcmp(&out, &before, sizeof(out)) && !error[2]); cases++; +} +static void rejected_raw(const uint8_t* raw, size_t size) { + char link[INVITE_LINK_MAX]; memcpy(link, "utun://", 7); + int len = EVP_EncodeBlock((unsigned char*)link + 7, raw, (int)size); + CHECK(len > 0); rejected(link, (size_t)len + 7); +} +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + struct InviteData input = { .channelId = 42, .join_key = UINT64_MAX }; + struct SC_MYKEYS keys; CHECK(sc_generate_keypair(&keys) == SC_OK); + memcpy(input.pubkey, keys.public_key, 32); + /* Полный нулевой ключ запрещён; нулевой префикс допустим. */ + input.pubkey[0] = 0; + char password[128]; memset(password, 'x', sizeof(password) - 1); password[127] = 0; + for (unsigned count = 1; count <= INVITE_ADDR_MAX; count++) for (unsigned mode = 0; mode < 5; mode++) { + input.addrCount = count; memset(&input.reality, 0, sizeof(input.reality)); + for (unsigned i = 0; i < count; i++) { + struct InviteAddr* a = &input.addrs[i]; memset(a, 0, sizeof(*a)); + a->family = mode == 0 ? 4 : (mode == 1 || mode == 4) ? 6 : (i & 1 ? 4 : 6); + a->port = (uint16_t)(1 + i * 1000); a->socketId = (uint8_t)(255 - i); + a->proto = mode >= 3 ? 6 : i & 1 ? 1 : 2; + for (unsigned j = 0; j < (a->family == 6 ? 16u : 4u); j++) a->address[j] = (uint8_t)(i + j + 1); + } + if (mode >= 3) { + input.reality.has = 1; input.reality.server_pubkey[0] = 7; + memset(input.reality.server_name, 'a', 63); input.reality.server_name[63] = 0; + } + const char* pass = (mode & 1 || mode == 4) ? password : NULL; + char link[INVITE_LINK_MAX], exact[INVITE_LINK_MAX], small[INVITE_LINK_MAX]; + CHECK(invite_link_encode(&input, pass, link, sizeof(link)) > 0); + size_t length = strlen(link); + if (mode == 4 && count == 32) CHECK(length + 1 == INVITE_LINK_MAX); + CHECK(length < INVITE_LINK_MAX && invite_link_encode(&input, pass, exact, length + 1) > 0); + CHECK(!strcmp(exact, link)); memset(small, 0xa5, sizeof(small)); + CHECK(invite_link_encode(&input, pass, small, length) < 0); + for (unsigned i = 0; i < sizeof(small); i++) CHECK((unsigned char)small[i] == 0xa5); + struct InviteData out; CHECK(!invite_link_decode(link, length, &out, NULL, 0)); + CHECK(out.channelId == input.channelId && out.join_key == input.join_key && out.addrCount == count); + CHECK(out.nodeId == sc_derive_node_id_from_pubkey(input.pubkey) && !memcmp(out.pubkey, input.pubkey, 32)); + CHECK(!strcmp(out.password, pass ? pass : "") && !memcmp(out.addrs, input.addrs, count * sizeof(input.addrs[0]))); + CHECK(!memcmp(&out.reality, &input.reality, sizeof(out.reality))); + uint8_t a[1024], b[1024]; int bytes = invite_serialize_addrs(&out, a, sizeof(a)); + CHECK(bytes > 0); memset(b, 0xa5, sizeof(b)); + CHECK(invite_serialize_addrs(&out, b, (size_t)bytes - 1) < 0); + for (unsigned i = 0; i < sizeof(b); i++) CHECK(b[i] == 0xa5); + CHECK(invite_serialize_addrs(&out, b, (size_t)bytes) == bytes && !memcmp(a, b, (size_t)bytes)); + /* Отказ при каждом усечении, кроме границ полных самостоятельных блоков. */ + for (size_t cut = 0; cut < length; cut++) { + struct InviteData partial; + if (invite_link_decode(link, cut, &partial, NULL, 0) == 0) { + CHECK(partial.addrCount > 0 && partial.addrCount < count); continue; + } + rejected(link, cut); + } + cases++; + } + for (unsigned i = 0; i < 256; i++) { + char link[INVITE_LINK_MAX]; CHECK(invite_link_encode(&input, NULL, link, sizeof(link)) > 0); + if (!((i >= 'A' && i <= 'Z') || (i >= 'a' && i <= 'z') || (i >= '0' && i <= '9') || i == '+' || i == '/')) { + link[12] = (char)i; rejected(link, strlen(link + 13) + 13); + } + } + char link[INVITE_LINK_MAX]; CHECK(invite_link_encode(&input, NULL, link, sizeof(link)) > 0); + uint8_t raw[1200]; int raw_len = EVP_DecodeBlock(raw, (const unsigned char*)link + 7, (int)strlen(link) - 7); + raw_len -= link[strlen(link) - 1] == '='; raw_len -= link[strlen(link) - 2] == '='; + uint8_t saved[1200]; memcpy(saved, raw, (size_t)raw_len); + raw[0] = 2; rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + raw[18] = 2; rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + raw[62] = 64; rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + raw[63] = 0; rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + raw[126] |= 0x80; rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + size_t second = 126 + 33 + 4 * 20; raw[second + 1] ^= 1; + rejected_raw(raw, (size_t)raw_len); memcpy(raw, saved, (size_t)raw_len); + rejected_raw(raw, 19); /* обязательный узел отсутствует */ + memcpy(raw, saved, (size_t)raw_len); raw[1] = 128; rejected_raw(raw, (size_t)raw_len); + struct InviteData bad = input; bad.addrCount = 0; CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.channelId = UINT64_MAX; CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.addrCount = 33; CHECK(invite_link_validate(&bad) < 0); + bad = input; memset(bad.pubkey, 0, 32); CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.addrs[0].family = 5; CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.addrs[0].port = 0; CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.addrs[0].proto = 3; CHECK(invite_link_validate(&bad) < 0); + bad = input; bad.reality.has = 0; CHECK(invite_link_validate(&bad) < 0); + rejected("utun://", 7); rejected(NULL, 1); + printf("invite codec: %u contract cases PASS\n", cases); return 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 47720da3..1ab08bfa 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 @@ -623,8 +623,7 @@ class MainActivity : AppCompatActivity() { if (showJoinDialog) { JoinChannelDialog( onConnect = { data: InviteData -> - val addrs = data.serializeAddrs() - NativeLib.joinChannel(data.channelId, data.nodeId, data.joinKey, data.pubkey, addrs, data.addrs.size) + vm.openInviteLink(data.link) showJoinDialog = false }, onDismiss = { showJoinDialog = false } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 262f5f06..6d0deb19 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -373,8 +373,7 @@ class ChatRepository { NativeLib.photoSend(channelId, filePath, width, height) fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId) fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex) - fun joinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) = - NativeLib.joinChannel(channelId, nodeId, joinKey, pubkey, addrs, addrCount) + fun joinChannel(link: String, request: Long): Int = NativeLib.joinChannel(link, request) fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId) fun connectMemberNode(channelId: String, nodeId: Long) = NativeLib.connectMemberNode(channelId, nodeId) fun isReady() = NativeLib.isReady() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt index 7902f22b..4d198104 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -1,8 +1,6 @@ package com.utun.chat.data -import android.util.Base64 import com.utun.chat.data.LogManager -import java.security.MessageDigest const val INVITE_LINK_VERSION: Int = 0x03 const val INVITE_PUBKEY_SIZE: Int = 32 @@ -43,36 +41,9 @@ data class InviteData( val nodeId: Long, val addrs: List, val password: String = "", - val reality: InviteReality? = null + val reality: InviteReality? = null, + val link: String = "" ) { - /** Serialize addresses for C bridge: reality-префикс + family(1)+socketId(1)+proto(1)+address(4|16)+port(2 BE) */ - fun serializeAddrs(): ByteArray { - var size = 1 - if (reality != null && reality.has) size += 8 + 32 + 3 + 1 + reality.serverName.toByteArray(Charsets.UTF_8).let { minOf(it.size, INVITE_SNAME_MAX - 1) } - size = addrs.fold(size) { acc, a -> acc + if (a.family == 4) 9 else 21 } - val buf = ByteArray(size) - var pos = 0 - buf[pos] = (if (reality != null && reality.has) 1 else 0).toByte(); pos++ - if (reality != null && reality.has) { - System.arraycopy(reality.shortId, 0, buf, pos, 8); pos += 8 - System.arraycopy(reality.serverPubkey, 0, buf, pos, 32); pos += 32 - System.arraycopy(reality.version, 0, buf, pos, 3); pos += 3 - val sn = reality.serverName.toByteArray(Charsets.UTF_8).let { if (it.size > INVITE_SNAME_MAX - 1) it.copyOf(INVITE_SNAME_MAX - 1) else it } - buf[pos] = sn.size.toByte(); pos++ - System.arraycopy(sn, 0, buf, pos, sn.size); pos += sn.size - } - for (a in addrs) { - buf[pos] = a.family.toByte(); pos++ - buf[pos] = a.socketId.toByte(); pos++ - buf[pos] = a.proto.toByte(); pos++ - val ipLen = if (a.family == 4) 4 else 16 - System.arraycopy(a.address, 0, buf, pos, ipLen); pos += ipLen - buf[pos] = ((a.port shr 8) and 0xFF).toByte(); pos++ - buf[pos] = (a.port and 0xFF).toByte(); pos++ - } - return buf - } - override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is InviteData) return false @@ -86,117 +57,24 @@ object InviteLink { private const val PREFIX = "utun://" fun decode(link: String): InviteData? { - LogManager.addLog("DEBUG", "INVITE", "decode: ${link.take(80)}") - if (!link.startsWith(PREFIX)) { - LogManager.addLog("WARN", "INVITE", "decode: missing utun:// prefix, len=${link.length}") - return null - } - val b64 = link.substring(PREFIX.length) - LogManager.addLog("DEBUG", "INVITE", "decode: base64 len=${b64.length}") - val raw: ByteArray - try { - raw = Base64.decode(b64, Base64.DEFAULT) + val encoded = NativeLib.decodeInvite(link) ?: return null + return try { + fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val value = org.json.JSONObject(encoded) + val addresses = value.getJSONArray("addresses") + val reality = value.optJSONObject("reality")?.let { + InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), + bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) + } + InviteData(value.getLong("channel"), value.getString("key").toULong().toLong(), + bytes(value.getString("pubkey")), value.getLong("node"), + (0 until addresses.length()).map { i -> + val a = addresses.getJSONObject(i) + InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) + }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) } catch (e: Exception) { - LogManager.addLog("ERROR", "INVITE", "decode: base64 failed: ${e.message}") - return null + LogManager.addLog("ERROR", "INVITE", "invalid native invitation preview: ${e.javaClass.simpleName}"); null } - LogManager.addLog("DEBUG", "INVITE", "decode: raw=${raw.size}B ver=${raw[0].toInt() and 0xFF}") - if (raw.size < 11) { - LogManager.addLog("ERROR", "INVITE", "decode: raw too short (${raw.size} < 11)") - return null - } - - var off = 0 - val ver = raw[off].toInt() and 0xFF - if (ver != INVITE_LINK_VERSION && ver != 0x02 && ver != 0x01) { - LogManager.addLog("ERROR", "INVITE", "decode: wrong version 0x%02x expected 0x%02x".format( - raw[off].toInt() and 0xFF, INVITE_LINK_VERSION)) - return null - } - off++ - - var password = "" - if (ver == 0x02 || ver == INVITE_LINK_VERSION) { - if (off >= raw.size) return null - val plen = raw[off].toInt() and 0xFF; off++ - if (plen > 0) { - if (off + plen > raw.size) return null - password = String(raw.copyOfRange(off, off + plen), Charsets.UTF_8) - off += plen - } - } - - var chId = 0L - for (i in 0 until 8) { - if (off + i >= raw.size) return null - chId = (chId shl 8) or (raw[off + i].toLong() and 0xFF) - } - off += 8 - - var joinKey = 0L - for (i in 0 until 8) { - if (off + i >= raw.size) return null - joinKey = (joinKey shl 8) or (raw[off + i].toLong() and 0xFF) - } - off += 8 - - var reality: InviteReality? = null - if (ver == INVITE_LINK_VERSION) { - if (off >= raw.size) return null - val has = (raw[off].toInt() and 0xFF) != 0; off++ - if (has) { - if (off + 8 + 32 + 3 + 1 > raw.size) return null - val shortId = raw.copyOfRange(off, off + 8); off += 8 - val serverPubkey = raw.copyOfRange(off, off + 32); off += 32 - val version = raw.copyOfRange(off, off + 3); off += 3 - var snlen = raw[off].toInt() and 0xFF; off++ - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1 - if (off + snlen > raw.size) return null - val serverName = String(raw.copyOfRange(off, off + snlen), Charsets.UTF_8) - off += snlen - reality = InviteReality(has = true, shortId = shortId, serverPubkey = serverPubkey, version = version, serverName = serverName) - } - } - - LogManager.addLog("DEBUG", "INVITE", "decode: chId=0x%016x joinKey=0x%016x".format(chId, joinKey)) - - var pubkey: ByteArray? = null - val addrs = mutableListOf() - var firstBlock = true - - while (off + 1 <= raw.size) { - val header = raw[off].toInt() and 0xFF; off++ - val cnt = (header and 0x03) + 1 - if (cnt < 1 || cnt > 4) return null - if (off + 32 > raw.size) return null - - if (firstBlock) { - pubkey = raw.copyOfRange(off, off + 32) - firstBlock = false - } - off += 32 - - for (j in 0 until cnt) { - val isV6 = ((header shr (2 + j)) and 1) != 0 - val ipLen = if (isV6) 16 else 4 - if (off + 2 + ipLen + 2 > raw.size) return null - val sockId = raw[off].toInt() and 0xFF; off++ - val proto = raw[off].toInt() and 0xFF; off++ - val ip = raw.copyOfRange(off, off + ipLen); off += ipLen - val port = ((raw[off].toInt() and 0xFF) shl 8) or (raw[off + 1].toInt() and 0xFF); off += 2 - val addr = InviteAddr(family = if (isV6) 6 else 4, address = ip, port = port, socketId = sockId, proto = proto) - addrs.add(addr) - LogManager.addLog("DEBUG", "INVITE", "decode: ${addrToString(addr)}") - } - } - - if (pubkey == null || pubkey.size != INVITE_PUBKEY_SIZE) return null - val nodeId = NativeLib.deriveNodeId(pubkey) - val pkHex = pubkey.take(8).joinToString("") { "%02x".format(it) } - LogManager.addLog("INFO", "INVITE", "decode: ch=0x%016x node=0x%016x pk=%s... addrs=%d".format( - chId, nodeId, pkHex, addrs.size)) - return InviteData(channelId = chId, joinKey = joinKey, pubkey = pubkey, nodeId = nodeId, - addrs = addrs, password = password, reality = reality) } fun addrToString(addr: InviteAddr): String { 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 5c1cec5d..931c2183 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 @@ -103,11 +103,10 @@ object NativeLib { nativeSendMessage(channelId, text.toByteArray(Charsets.UTF_8), messageIdBytes(replyTo), requestId) fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } - fun joinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { - LogManager.addLog("INFO", "JNI", "joinChannel: calling native") - nativeJoinChannel(channelId, nodeId, joinKey, pubkey, addrs, addrCount) - LogManager.addLog("INFO", "JNI", "joinChannel: native returned") - } + private val joinSequence = java.util.concurrent.atomic.AtomicLong(1) + fun nextJoinRequest(): Long = joinSequence.getAndIncrement() + fun joinChannel(link: String, request: Long): Int = nativeJoinChannel(link, request) + fun decodeInvite(link: String): String? = nativeDecodeInvite(link) fun deriveNodeId(pubkey: ByteArray): Long = nativeDeriveNodeId(pubkey) fun connectChannel(channelId: String) { nativeConnectChannel(channelId) } fun connectMemberNode(channelId: String, nodeId: Long) { nativeConnectMemberNode(channelId, nodeId) } @@ -348,7 +347,8 @@ object NativeLib { private external fun nativeSendMessage(channelId: String, text: ByteArray, replyTo: ByteArray, requestId: Long): Boolean private external fun nativeCreateChannel(name: String, channelId: String) private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String) - private external fun nativeJoinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) + private external fun nativeJoinChannel(link: String, request: Long): Int + private external fun nativeDecodeInvite(link: String): String? private external fun nativeDeriveNodeId(pubkey: ByteArray): Long private external fun nativeConnectChannel(channelId: String) private external fun nativeConnectMemberNode(channelId: String, nodeId: Long) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index bf55619e..0d63548c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -128,7 +128,7 @@ class ChatViewModel : ViewModel() { private var nextPmInviteId = 0L data class LinkJoinState(val link: String, val channelId: Long, val nodeId: Long, - val pending: Boolean = true, val result: Int? = null) + val pending: Boolean = true, val result: Int? = null, val request: Long = 0) private val _linkJoin = MutableStateFlow(null) val linkJoin: StateFlow = _linkJoin private val _joinedChannel = MutableStateFlow(null) @@ -557,13 +557,14 @@ class ChatViewModel : ViewModel() { if (_currentChannel.value?.id == channel) refreshMessages(channel) } 2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */ - if (repo == null || data == null || data.size < 20) { LogManager.addLog("WARN", "VM", "CONNECT_RESULT skipped repo=${repo != null} data=${data != null}"); return } + if (repo == null || data == null || data.size < 28) { LogManager.addLog("WARN", "VM", "CONNECT_RESULT skipped repo=${repo != null} data=${data != null}"); return } val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) val nodeId = buf.getLong() val result = buf.getInt() val chId = buf.getLong() + val request = buf.getLong() val joining = _linkJoin.value - if (joining != null && joining.pending && joining.channelId == chId && joining.nodeId == nodeId) { + if (joining != null && joining.pending && joining.request == request && joining.channelId == chId && joining.nodeId == nodeId) { _linkJoin.value = joining.copy(pending = false, result = result) if (result == 0) viewModelScope.launch { val channels = withContext(Dispatchers.IO) { repo?.getChannels().orEmpty() } @@ -1310,9 +1311,10 @@ class ChatViewModel : ViewModel() { LogManager.addLog("ERROR", "JOIN", "core unavailable for message invite") _linkJoin.value = LinkJoinState(link, invite.channelId, invite.nodeId, pending = false, result = -7); return } - _linkJoin.value = LinkJoinState(link, invite.channelId, invite.nodeId) - val addresses = invite.serializeAddrs() - joinChannel(invite.channelId, invite.nodeId, invite.joinKey, invite.pubkey, addresses, invite.addrs.size) + val request = NativeLib.nextJoinRequest() + _linkJoin.value = LinkJoinState(link, invite.channelId, invite.nodeId, request = request) + val result = repo?.joinChannel(link, request) ?: -7 + if (result != 0) _linkJoin.value = _linkJoin.value?.copy(pending = false, result = result) } fun dismissLinkJoin() { _linkJoin.value = null } @@ -1429,13 +1431,6 @@ class ChatViewModel : ViewModel() { r.connectNode(address, port, pubkeyHex) } - fun joinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { - val r = repo - if (r == null) { LogManager.addLog("ERROR", "VM", "joinChannel: repo is NULL"); return } - LogManager.addLog("INFO", "VM", "joinChannel: ch=0x%016x node=0x%016x".format(channelId, nodeId)) - r.joinChannel(channelId, nodeId, joinKey, pubkey, addrs, addrCount) - } - private var recordingStopping = false private var recordingTarget: ConversationTarget? = null private var recordingReply: ChannelReply? = null diff --git a/tools/chatgui-android/headless/headless_control.c b/tools/chatgui-android/headless/headless_control.c index 49b16c46..ea06679a 100644 --- a/tools/chatgui-android/headless/headless_control.c +++ b/tools/chatgui-android/headless/headless_control.c @@ -3,7 +3,7 @@ */ #include "headless_control.h" #include "../libutun_lite/utun_config_api.h" -#include "../libutun_lite/invite_link_c.h" +#include "../../../src/chat/invite_link.h" #include "../libutun_lite/instance_lite.h" #include "../../../src/chat/chat_setting.h" #include "../../../src/chat/chat_sync.h" @@ -288,7 +288,7 @@ static int handle_join(int fd, int id, const char* json) { send_response(fd, id, NULL, "missing link"); return 0; } - struct InviteDataC d; + struct InviteData d; char err[256]; if (invite_link_decode(link, strlen(link), &d, err, sizeof(err)) < 0) { send_response(fd, id, NULL, err); @@ -300,7 +300,7 @@ static int handle_join(int fd, int id, const char* json) { d.pubkey[0], d.pubkey[1], d.pubkey[2], d.pubkey[3], (int)d.addrCount); for (int i = 0; i < (int)d.addrCount; i++) { - struct InviteAddrC* a = &d.addrs[i]; + struct InviteAddr* a = &d.addrs[i]; char ip[64]; snprintf(ip, sizeof(ip), a->family == 6 ? "v6" : "%d.%d.%d.%d", a->address[0], a->address[1], a->address[2], a->address[3]); const char* proto = a->proto == 1 ? "UDP" : a->proto == 2 ? "TCP" : a->proto == 3 ? "UDP+TCP" : "?"; @@ -315,14 +315,14 @@ static int handle_join(int fd, int id, const char* json) { return 0; } - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "join: calling chat_sync_connect_from_invite..."); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "join: calling chat_sync_join_link..."); struct UTUN_INSTANCE* inst = instance_lite_get_instance(); if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "join: no instance running — drop"); send_response(fd, id, NULL, "instance not running"); return 0; } - chat_sync_connect_from_invite(inst, d.channelId, d.nodeId, d.pubkey, addrs_buf, d.addrCount, addrs_len, d.password, d.join_key); + chat_sync_join_link(inst, 1, link, strlen(link)); 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 343893fb..a0a3302d 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -3,13 +3,13 @@ * * C side of the bridge. Wires Kotlin ↔ uTun stack: * - nativeStart(configText) → instance_lite_start() - * - nativeJoinChannel(chId, nodeId, pubkey, addrs) → chat_sync_connect_from_invite() + * - nativeJoinChannel(chId, nodeId, pubkey, addrs) → chat_sync_join_link() * - Chat events → JNI callback → Kotlin */ #include "android_jni_bridge.h" #include "android_udp_log.h" #include "../libutun_lite/utun_config_api.h" -#include "../libutun_lite/invite_link_c.h" +#include "../../../src/chat/invite_link.h" #include "../libutun_lite/instance_lite.h" #include "../libutun_lite/voice_recorder.h" #include "../libutun_lite/attachment_sender.h" @@ -233,35 +233,12 @@ void utun_bridge_connect_node(const char* address, int port, const char* pubkey_ bridge_log(BLEV_INFO, "connect %s:%d pubkey=%s", address ? address : "", port, pubkey_hex ? pubkey_hex : ""); } -/* Real implementation: calls chat_sync_connect_from_invite() */ +/* Real implementation: calls chat_sync_join_link() */ #include "chat/chat_sync.h" +#include "chat/invite_link.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, - const uint8_t* addrs_data, int addr_count, - int addrs_data_len, uint64_t join_key) { - uint64_t real_node_id = sc_derive_node_id_from_pubkey(pubkey_bin); - if (real_node_id != node_id) - bridge_log(BLEV_WARN, "join: node_id Kotlin=0x%016llx != C=0x%016llx — using C value", - (unsigned long long)node_id, (unsigned long long)real_node_id); - bridge_log(BLEV_INFO, "join ch=%llu node=0x%016llx addrs=%d len=%d", - (unsigned long long)channel_id, (unsigned long long)real_node_id, addr_count, addrs_data_len); - if (!pubkey_bin || !addrs_data || addr_count <= 0) { - bridge_log(BLEV_ERROR, "join invalid args"); - utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */ - return; - } - struct UTUN_INSTANCE* inst = instance_lite_get_instance(); - if (!inst) { - bridge_log(BLEV_ERROR, "join: instance not running"); - utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */ - return; - } - chat_sync_connect_from_invite(inst, channel_id, real_node_id, pubkey_bin, addrs_data, addr_count, addrs_data_len, NULL, join_key); -} - void utun_bridge_request_invite_candidates(const char* channel_id) { if (!channel_id || !channel_id[0]) { bridge_log(BLEV_ERROR, "request_invite_candidates: null ch_id"); @@ -1919,27 +1896,56 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit( /* ── nativeJoinChannel ── */ -JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( - JNIEnv* env, jobject thiz, jlong channelId, jlong nodeId, jlong joinKey, - jbyteArray pubkey, jbyteArray addrsData, jint addrCount) { +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( + JNIEnv* env, jobject thiz, jstring link, jlong request) { (void)thiz; - if (!pubkey || !addrsData) { - bridge_log(BLEV_ERROR, "nativeJoinChannel null args"); - return; + if (!link) return CHAT_CONNECT_INVALID; + const char* text = (*env)->GetStringUTFChars(env, link, NULL); + if (!text) return CHAT_CONNECT_ERROR; + int result = instance_lite_join_link((uint64_t)request, text, strlen(text)); + (*env)->ReleaseStringUTFChars(env, link, text); return result; +} + +/* Представление результата общего C-кодека для предпросмотра Kotlin. */ +static int invite_hex_json(struct strbuf* out, const uint8_t* bytes, size_t len) { + char hex[257]; + if (len > 128) return -1; + for (size_t i = 0; i < len; i++) snprintf(hex + i * 2, 3, "%02x", bytes[i]); + hex[len * 2] = 0; return strbuf_add_json_string(out, hex); +} +JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDecodeInvite( + JNIEnv* env, jobject thiz, jstring link) { + (void)thiz; + if (!link) return NULL; + const char* text = (*env)->GetStringUTFChars(env, link, NULL); + if (!text) return NULL; + struct InviteData data; + int result = invite_link_decode(text, strlen(text), &data, NULL, 0); + (*env)->ReleaseStringUTFChars(env, link, text); + if (result) return NULL; + struct strbuf out = strbuf_new(); + if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":%llu,\"pubkey\":", + (unsigned long long)data.channelId, (unsigned long long)data.join_key, (unsigned long long)data.nodeId) < 0 || + invite_hex_json(&out, data.pubkey, 32) < 0 || strbuf_addf(&out, ",\"password\":") < 0 || + invite_hex_json(&out, (const uint8_t*)data.password, data.password_len) < 0) goto failed; + if (data.reality.has) { + if (strbuf_addf(&out, ",\"reality\":{\"short\":") < 0 || invite_hex_json(&out, data.reality.short_id, 8) < 0 || + strbuf_addf(&out, ",\"pubkey\":") < 0 || invite_hex_json(&out, data.reality.server_pubkey, 32) < 0 || + strbuf_addf(&out, ",\"version\":") < 0 || invite_hex_json(&out, data.reality.version, 3) < 0 || + strbuf_addf(&out, ",\"name\":") < 0 || invite_hex_json(&out, (const uint8_t*)data.reality.server_name, strlen(data.reality.server_name)) < 0 || + strbuf_addf(&out, "}") < 0) goto failed; } - jsize pk_len = (*env)->GetArrayLength(env, pubkey); - jsize ad_len = (*env)->GetArrayLength(env, addrsData); - if (pk_len < 32 || ad_len <= 0) { - bridge_log(BLEV_ERROR, "nativeJoinChannel bad sizes pk=%d ad=%d", (int)pk_len, (int)ad_len); - return; + if (strbuf_addf(&out, ",\"addresses\":[") < 0) goto failed; + for (unsigned i = 0; i < data.addrCount; i++) { + struct InviteAddr* a = &data.addrs[i]; + if (strbuf_addf(&out, "%s{\"family\":%d,\"socket\":%u,\"proto\":%u,\"port\":%u,\"ip\":", + i ? "," : "", a->family, a->socketId, a->proto, a->port) < 0 || + invite_hex_json(&out, a->address, a->family == 6 ? 16 : 4) < 0 || strbuf_addf(&out, "}") < 0) goto failed; } - uint8_t* pk = (uint8_t*)(*env)->GetByteArrayElements(env, pubkey, NULL); - uint8_t* ad = (uint8_t*)(*env)->GetByteArrayElements(env, addrsData, NULL); - bridge_log(BLEV_DEBUG, "nativeJoinChannel ch=%lld node=0x%llx pk=%02x%02x... addrs_sz=%d", - (long long)channelId, (unsigned long long)nodeId, pk[0], pk[1], (int)ad_len); - utun_bridge_join_channel((uint64_t)channelId, (uint64_t)nodeId, pk, ad, (int)addrCount, (int)ad_len, (uint64_t)joinKey); - (*env)->ReleaseByteArrayElements(env, addrsData, (jbyte*)ad, JNI_ABORT); - (*env)->ReleaseByteArrayElements(env, pubkey, (jbyte*)pk, JNI_ABORT); + if (strbuf_addf(&out, "]}") < 0) goto failed; + jstring value = (*env)->NewStringUTF(env, strbuf_str(&out)); strbuf_free(&out); return value; +failed: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "invite preview allocation failed"); strbuf_free(&out); return NULL; } /* ── nativeDeriveNodeId ── */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index d8fc932a..fce1a173 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -51,14 +51,6 @@ int utun_bridge_send_message(const char* channel_id, const char* text, const uin void utun_bridge_create_channel(const char* name, const char* channel_id); void utun_bridge_connect_node(const char* address, int port, const char* pubkey_hex); -/* Join channel via invite link. - addrs_data — результат invite_serialize_addrs: Reality-префикс и - family(1) + socketId(1) + proto(1) + address(4|16) + port(2 BE) для каждого адреса. */ -void utun_bridge_join_channel(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, uint64_t join_key); - /* Connect to all peers of a channel (auto-connect on select) */ void utun_bridge_connect_channel(const char* channel_id); diff --git a/tools/chatgui-android/libutun_lite/instance_lite.c b/tools/chatgui-android/libutun_lite/instance_lite.c index 38be75da..36d65414 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.c +++ b/tools/chatgui-android/libutun_lite/instance_lite.c @@ -16,6 +16,7 @@ #include "topo_node_sqlite.h" #include "chat/chat_core.h" #include "chat/chat_sync.h" +#include "chat/chat_join_port.h" #include "chat/chat_event.h" #include "chat/db_sync.h" #include "u_async.h" @@ -67,6 +68,21 @@ static struct UASYNC* g_ua = NULL; /* C-write, Kotlin-read via __atomic */ static struct UTUN_INSTANCE* g_inst = NULL; /* C-write, Kotlin-read via __atomic */ static pthread_t g_thread; +static pthread_mutex_t join_port_lock = PTHREAD_MUTEX_INITIALIZER; +static struct chat_join_port* join_port; +static void bind_join_port(struct UTUN_INSTANCE* inst) { + struct chat_join_port* next = inst ? chat_sync_join_port(inst) : NULL; + pthread_mutex_lock(&join_port_lock); + struct chat_join_port* old = join_port; join_port = next; + pthread_mutex_unlock(&join_port_lock); + chat_join_port_release(old); +} +int instance_lite_join_link(uint64_t request, const char* link, size_t len) { + pthread_mutex_lock(&join_port_lock); + int result = chat_join_port_submit(join_port, request, link, len); + pthread_mutex_unlock(&join_port_lock); return result; +} + static volatile int g_stop = 0; /* Kotlin-write, C-read (poll loop) */ static volatile int g_do_restart = 0; /* Kotlin-write, C atomic-xchg→0 */ static volatile char* g_restart_config = NULL; /* Kotlin-write, C atomic-xchg→NULL (ownership transfer) */ @@ -314,6 +330,7 @@ static void* instance_thread(void* arg) { return NULL; } + bind_join_port(g_inst); /* sockets are now created — sync my addresses to DB */ call_video_port_attach(g_inst); chat_core_sync_my_addresses(g_inst); @@ -393,6 +410,7 @@ static void* instance_thread(void* arg) { chat_service_start(g_inst) != 0 || android_stream_attach(g_inst) < 0) { IL_LOGE("poll exit: core/chat/stream bridge start failed"); break; } + bind_join_port(g_inst); chat_core_sync_my_addresses(g_inst); call_video_port_attach(g_inst); fire_local_sockets_event(); @@ -412,6 +430,7 @@ static void* instance_thread(void* arg) { IL_LOGI("poll exit: final cleanup"); if (my_gen == g_generation) { + bind_join_port(NULL); __atomic_store_n(&g_running, 0, __ATOMIC_RELEASE); if (my_inst) { chat_event_post(my_inst, CHAT_EVT_SERVICE_STOPPED, NULL, 0); @@ -495,6 +514,7 @@ int instance_lite_start(const char* config_text) { } void instance_lite_stop(void) { + bind_join_port(NULL); struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)__atomic_load_n(&g_inst, __ATOMIC_ACQUIRE); if (!inst) return; IL_LOGI("stopping..."); @@ -576,6 +596,7 @@ static void log_config_servers(const char* cfg, const char* label) { } void instance_lite_restart(const char* new_config_text) { + bind_join_port(NULL); if (!new_config_text) return; struct UASYNC* ua = (struct UASYNC*)__atomic_load_n(&g_ua, __ATOMIC_ACQUIRE); struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)__atomic_load_n(&g_inst, __ATOMIC_ACQUIRE); diff --git a/tools/chatgui-android/libutun_lite/instance_lite.h b/tools/chatgui-android/libutun_lite/instance_lite.h index 9664c46a..152cd443 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.h +++ b/tools/chatgui-android/libutun_lite/instance_lite.h @@ -7,6 +7,7 @@ #define INSTANCE_LITE_H #include +#include #ifdef __cplusplus extern "C" { @@ -31,6 +32,8 @@ struct UASYNC* instance_lite_get_uasync(void); /* Get current UTUN_INSTANCE handle (NULL if not running). */ struct UTUN_INSTANCE* instance_lite_get_instance(void); +/* Потокобезопасный JOIN: 0 — принят с request, <0 — отказ без события. */ +int instance_lite_join_link(uint64_t request, const char* link, size_t len); /* Set Kotlin callback for chat events (CONNECT_RESULT, MSG_RECEIVED, ...) */ typedef void (*instance_lite_event_fn)(int type, const uint8_t* data, int len); diff --git a/tools/chatgui/src/invite_link.cpp b/tools/chatgui/src/invite_link.cpp index 8c6cd43e..b8499c04 100644 --- a/tools/chatgui/src/invite_link.cpp +++ b/tools/chatgui/src/invite_link.cpp @@ -1,211 +1,34 @@ -#include #include "invite_link.h" -#include "debug_ui.h" -#include - -QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, - const QList& addrs, - const QString& password) { - if (pubkey.size() != 32) { - GUI_ERROR("encodeInviteLink: pubkey must be 32 bytes (got %d)", pubkey.size()); - return {}; - } - if (addrs.isEmpty()) { - GUI_ERROR("encodeInviteLink: no addresses"); - return {}; - } - - QByteArray blob; - int hasPass = !password.isEmpty() ? 1 : 0; - blob.reserve(1 + (hasPass ? 1 + password.size() : 0) + 8 + addrs.size() * (1 + 32 + 20)); - - 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--) - blob.append(static_cast((channelId >> (i * 8)) & 0xFF)); - - int i = 0; - while (i < addrs.size()) { - int rem = addrs.size() - i; - int cnt = (rem > 4) ? 4 : rem; - - /* header: bits 1-0 = count-1, bits 5-2 = family bits */ - uint8_t header = (cnt - 1) & 0x03; - for (int j = 0; j < cnt; j++) { - if (addrs[i + j].family == 6) - header |= (1 << (2 + j)); - } - blob.append(static_cast(header)); - blob.append(pubkey); - - for (int j = 0; j < cnt; j++) { - blob.append(static_cast(addrs[i + j].socketId)); - blob.append(static_cast(addrs[i + j].protocol)); - blob.append(addrs[i + j].address); - quint16 p = addrs[i + j].port; - blob.append(static_cast((p >> 8) & 0xFF)); - blob.append(static_cast(p & 0xFF)); - } - i += cnt; - } - - return QString::fromLatin1("utun://") + QString::fromLatin1(blob.toBase64()); +#define InviteData CoreInviteData +#define InviteAddr CoreInviteAddr +#define InviteReality CoreInviteReality +extern "C" { +#include "../../../src/chat/invite_link.h" } +#undef InviteData +#undef InviteAddr +#undef InviteReality InviteData decodeInviteLink(const QString& link) { - InviteData d; - if (!link.startsWith(QLatin1String("utun://"))) { - d.error = QCoreApplication::translate("ChatGui", "invalid prefix (expected utun://)"); - GUI_ERROR("decodeInviteLink: invalid prefix"); - return d; + InviteData result; + CoreInviteData data; char error[128]; + const QByteArray bytes = link.toUtf8(); + if (invite_link_decode(bytes.constData(), (size_t)bytes.size(), &data, error, sizeof(error))) { + result.error = QString::fromUtf8(error); return result; } - QByteArray blob = QByteArray::fromBase64(link.mid(7).toLatin1()); - if (blob.isEmpty()) { - d.error = QCoreApplication::translate("ChatGui", "base64 decode failed"); - GUI_ERROR("decodeInviteLink: base64 decode failed"); - return d; + result.channelId = data.channelId; result.joinKey = data.join_key; + result.pubkey = QByteArray((const char*)data.pubkey, 32); + result.password = QString::fromUtf8(data.password, data.password_len); + result.reality.has = data.reality.has; + if (data.reality.has) { + result.reality.shortId = QByteArray((const char*)data.reality.short_id, 8); + result.reality.serverPubkey = QByteArray((const char*)data.reality.server_pubkey, 32); + result.reality.version = QByteArray((const char*)data.reality.version, 3); + result.reality.serverName = QString::fromUtf8(data.reality.server_name); } - const auto* data = reinterpret_cast(blob.constData()); - int len = blob.size(); - - if (len < 11) { - d.error = QCoreApplication::translate("ChatGui", "blob too short (%1 bytes)").arg(len); - GUI_ERROR("decodeInviteLink: blob too short (%d bytes)", len); - return d; + for (unsigned i = 0; i < data.addrCount; i++) { + const auto& a = data.addrs[i]; + result.addrs.append({a.family, QByteArray((const char*)a.address, a.family == 6 ? 16 : 4), a.port, a.socketId, a.proto}); } - int off = 0; - - uint8_t ver = data[off++]; - if (ver != INVITE_LINK_VERSION && ver != 0x02 && ver != 0x01) { - d.error = QCoreApplication::translate("ChatGui", "unsupported version 0x%1").arg(ver, 2, 16, QLatin1Char('0')); - GUI_ERROR("decodeInviteLink: unsupported version %d", ver); - return d; - } - - /* v2/v3: password field before channel_id */ - if (ver == 0x02 || ver == INVITE_LINK_VERSION) { - if (off >= len) { - d.error = QCoreApplication::translate("ChatGui", "truncated at pass_len"); - return d; - } - uint8_t plen = data[off++]; - if (plen > 0) { - if (off + plen > len) { - d.error = QCoreApplication::translate("ChatGui", "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 = QCoreApplication::translate("ChatGui", "truncated at channel_id"); - GUI_ERROR("decodeInviteLink: truncated at channel_id"); - return d; - } - uint64_t chId = 0; - for (int i = 0; i < 8; i++) - chId = (chId << 8) | data[off++]; - d.channelId = chId; - - /* join_key: uint64 big-endian */ - if (off + 8 > len) { - d.error = QCoreApplication::translate("ChatGui", "truncated at join_key"); - GUI_ERROR("decodeInviteLink: truncated at join_key"); - return d; - } - uint64_t jk = 0; - for (int i = 0; i < 8; i++) - jk = (jk << 8) | data[off++]; - d.joinKey = jk; - - /* v3: reality-блок (node-global параметры) */ - if (ver == INVITE_LINK_VERSION) { - if (off >= len) { d.error = QCoreApplication::translate("ChatGui", "truncated at reality"); return d; } - if (data[off++] != 0) { - if (off + 8 + 32 + 3 + 1 > len) { d.error = QCoreApplication::translate("ChatGui", "truncated at reality params"); return d; } - d.reality.shortId = QByteArray(reinterpret_cast(data + off), 8); off += 8; - d.reality.serverPubkey = QByteArray(reinterpret_cast(data + off), 32); off += 32; - d.reality.version = QByteArray(reinterpret_cast(data + off), 3); off += 3; - uint8_t snlen = data[off++]; - if (snlen > INVITE_SNAME_MAX - 1) snlen = INVITE_SNAME_MAX - 1; - if (off + snlen > len) { d.error = QCoreApplication::translate("ChatGui", "truncated at reality server_name"); return d; } - d.reality.serverName = QString::fromUtf8(reinterpret_cast(data + off), snlen); - off += snlen; - d.reality.has = true; - } - } - - bool firstBlock = true; - while (off + 1 <= len) { - uint8_t header = data[off++]; - int cnt = (header & 0x03) + 1; - if (cnt < 1 || cnt > 4) { - d.error = QCoreApplication::translate("ChatGui", "invalid addr count %1").arg(cnt); - GUI_ERROR("decodeInviteLink: invalid addr count %d", cnt); - return d; - } - if (off + 32 > len) { - d.error = QCoreApplication::translate("ChatGui", "truncated at pubkey"); - GUI_ERROR("decodeInviteLink: truncated at pubkey"); - return d; - } - if (firstBlock) { - d.pubkey = QByteArray(reinterpret_cast(data + off), 32); - firstBlock = false; - } - off += 32; - - for (int j = 0; j < cnt; j++) { - bool isV6 = header & (1 << (2 + j)); - int ipLen = isV6 ? 16 : 4; - if (off + 2 + ipLen + 2 > len) { - d.error = QCoreApplication::translate("ChatGui", "truncated at addr %1 of block").arg(j); - GUI_ERROR("decodeInviteLink: truncated at addr %d of block", j); - return d; - } - InviteAddr a; - a.socketId = data[off++]; - a.proto = data[off++]; - a.family = isV6 ? 6 : 4; - a.address = QByteArray(reinterpret_cast(data + off), ipLen); - off += ipLen; - a.port = (static_cast(data[off]) << 8) | data[off + 1]; - off += 2; - d.addrs.append(a); - } - } - - return d; -} - -QByteArray serializeInviteAddrs(const InviteData& d) { - QByteArray buf; - buf.append(static_cast(d.reality.has ? 1 : 0)); - if (d.reality.has) { - buf.append(d.reality.shortId.leftJustified(8, '\0', true)); - buf.append(d.reality.serverPubkey.leftJustified(32, '\0', true)); - buf.append(d.reality.version.leftJustified(3, '\0', true)); - QByteArray sn = d.reality.serverName.toUtf8().left(INVITE_SNAME_MAX - 1); - buf.append(static_cast(sn.size())); - buf.append(sn); - } - for (const auto& a : d.addrs) { - buf.append(static_cast(a.family)); - buf.append(static_cast(a.socketId)); - buf.append(static_cast(a.proto)); - buf.append(a.address); - buf.append(static_cast((a.port >> 8) & 0xFF)); - buf.append(static_cast(a.port & 0xFF)); - } - return buf; + return result; } diff --git a/tools/chatgui/src/invite_link.h b/tools/chatgui/src/invite_link.h index b9b808e9..2f2475a4 100644 --- a/tools/chatgui/src/invite_link.h +++ b/tools/chatgui/src/invite_link.h @@ -43,11 +43,4 @@ struct InviteData { InviteReality reality; }; -QString encodeInviteLink(uint64_t channelId, const QByteArray& pubkey, - const QList& addrs, - const QString& password = QString()); - InviteData decodeInviteLink(const QString& link); - -/* reality-префикс + адреса: has(1) [short_id(8) pubkey(32) ver(3) snlen(1) sn(N)] addrs*(family sockId proto addr port) */ -QByteArray serializeInviteAddrs(const InviteData& d); diff --git a/tools/chatgui/src/inviteby.cpp b/tools/chatgui/src/inviteby.cpp index 04ec2f78..f5881d73 100644 --- a/tools/chatgui/src/inviteby.cpp +++ b/tools/chatgui/src/inviteby.cpp @@ -65,9 +65,9 @@ InviteByLinkDialog::InviteByLinkDialog(const QString& channelId, UtunNode* node, connect(m_inviteLinkEdit, &QLineEdit::returnPressed, this, &InviteByLinkDialog::onSendInvite); connect(m_inviteLinkEdit, &QLineEdit::textChanged, this, &InviteByLinkDialog::onInviteLinkTextChanged); - gui_bridge_set_connect_result_cb([](uint64_t nodeId, uint64_t channelId, int result) { + gui_bridge_set_connect_result_cb([](uint64_t nodeId, uint64_t channelId, int result, uint64_t request) { InviteByLinkDialog* dlg = qobject_cast(QApplication::activeModalWidget()); - if (dlg) dlg->onConnectResult(nodeId, channelId, result); + if (dlg) dlg->onConnectResult(nodeId, channelId, result, request); }); } @@ -180,18 +180,15 @@ void InviteByLinkDialog::onSendInvite() { m_channelId.toUtf8().constData(), targetNodeId, d.addrs.size()); /* Save addresses to DB */ - QByteArray addrsBuf = serializeInviteAddrs(d); - - chat_sync_invite_to_channel_with_addrs( - gui_bridge_get_inst(), m_channelId.toUtf8().constData(), targetNodeId, - (const uint8_t*)d.pubkey.constData(), - (const uint8_t*)addrsBuf.constData(), d.addrs.size(), addrsBuf.size()); + const QByteArray bytes = link.toUtf8(); + int result = gui_bridge_offer_link(m_channelId.toULongLong(), bytes.constData(), (size_t)bytes.size(), &m_request); + if (result) { onConnectResult(targetNodeId, m_channelId.toULongLong(), result, m_request); return; } setInviteStatus(QCoreApplication::translate("ChatGui", "Invitation sent, waiting for a response...")); } -void InviteByLinkDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result) { - if (!m_inviteSending || nodeId != m_inviteTargetNodeId) return; +void InviteByLinkDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result, uint64_t request) { + if (request != m_request || !m_inviteSending || nodeId != m_inviteTargetNodeId) return; if (result == 0) { setInviteStatus(QCoreApplication::translate("ChatGui", "The node has joined the channel."), false); diff --git a/tools/chatgui/src/inviteby.h b/tools/chatgui/src/inviteby.h index 887d0514..c016c244 100644 --- a/tools/chatgui/src/inviteby.h +++ b/tools/chatgui/src/inviteby.h @@ -18,7 +18,7 @@ public: private slots: void onInviteLinkTextChanged(); void onSendInvite(); - void onConnectResult(uint64_t nodeId, uint64_t channelId, int result); + void onConnectResult(uint64_t nodeId, uint64_t channelId, int result, uint64_t request); private: void setInviteStatus(const QString& text, bool isError = false); @@ -29,5 +29,6 @@ private: QLabel* m_inviteStatusLabel = nullptr; QPushButton* m_inviteSendBtn = nullptr; bool m_inviteSending = false; + uint64_t m_request = 0; uint64_t m_inviteTargetNodeId = 0; }; diff --git a/tools/chatgui/src/joindialog.cpp b/tools/chatgui/src/joindialog.cpp index f4ed3325..4c40e2cd 100644 --- a/tools/chatgui/src/joindialog.cpp +++ b/tools/chatgui/src/joindialog.cpp @@ -120,9 +120,9 @@ JoinDialog::JoinDialog(UtunNode* node, DbManager* db, QWidget* parent, .arg(addrList.join(", "))); }); - gui_bridge_set_connect_result_cb([](uint64_t nodeId, uint64_t channelId, int result) { + gui_bridge_set_connect_result_cb([](uint64_t nodeId, uint64_t channelId, int result, uint64_t request) { JoinDialog* dlg = qobject_cast(QApplication::activeModalWidget()); - if (dlg) dlg->onConnectResult(nodeId, channelId, result); + if (dlg) dlg->onConnectResult(nodeId, channelId, result, request); }); if (!autoJoinLink.isEmpty()) { @@ -194,18 +194,14 @@ void JoinDialog::onConnectClicked() { setStatus(QCoreApplication::translate("ChatGui", "Connecting to peer...")); - QByteArray addrsBuf = serializeInviteAddrs(d); + const QByteArray bytes = link.toUtf8(); + int result = gui_bridge_join_link(bytes.constData(), (size_t)bytes.size(), &m_request); + if (result) onConnectResult(nodeId, d.channelId, result, m_request); - int addrCount = d.addrs.size(); - const char* pass = d.password.isEmpty() ? NULL : d.password.toUtf8().constData(); - - chat_sync_connect_from_invite(gui_bridge_get_inst(), d.channelId, nodeId, - (const uint8_t*)d.pubkey.constData(), - (const uint8_t*)addrsBuf.constData(), addrCount, addrsBuf.size(), pass, d.joinKey); } -void JoinDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result) { - if (!m_connecting || nodeId != m_pendingNodeId || channelId != m_pendingChannelId) return; +void JoinDialog::onConnectResult(uint64_t nodeId, uint64_t channelId, int result, uint64_t request) { + if (request != m_request || !m_connecting || nodeId != m_pendingNodeId || channelId != m_pendingChannelId) return; if (result == 0) { setStatus(QCoreApplication::translate("ChatGui", "Connected!")); diff --git a/tools/chatgui/src/joindialog.h b/tools/chatgui/src/joindialog.h index 8dc88ed3..eeebaba8 100644 --- a/tools/chatgui/src/joindialog.h +++ b/tools/chatgui/src/joindialog.h @@ -28,7 +28,7 @@ private slots: private: void setStatus(const QString& text, bool isError = false); - void onConnectResult(uint64_t nodeId, uint64_t channelId, int result); + void onConnectResult(uint64_t nodeId, uint64_t channelId, int result, uint64_t request); UtunNode* m_node; DbManager* m_db; @@ -38,6 +38,7 @@ private: QPushButton* m_cancelBtn; QTimer* m_decodeTimer; bool m_connecting = false; + uint64_t m_request = 0; uint64_t m_pendingNodeId = 0; uint64_t m_pendingChannelId = 0; }; diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index cfcb1b1c..5aa300e3 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -83,7 +83,9 @@ void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg); int gui_bridge_is_uasync_ready(void); /* Callback для результатов подключения (вызывается из GUI-потока) */ -typedef void (*gui_connect_result_fn)(uint64_t node_id, uint64_t channel_id, int result); +typedef void (*gui_connect_result_fn)(uint64_t node_id, uint64_t channel_id, int result, uint64_t request); +int gui_bridge_offer_link(uint64_t channel, const char* link, size_t len, uint64_t* request); +int gui_bridge_join_link(const char* link, size_t len, uint64_t* request); void gui_bridge_set_connect_result_cb(gui_connect_result_fn cb); /* Callback для входящих сообщений (вызывается из GUI-потока) */ diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 57a54061..f8e082c1 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include extern "C" { #include "../../../lib/u_async.h" @@ -15,6 +17,7 @@ extern "C" { #include "../../../src/member_files/protocol.h" #include "../../../src/chat/chat_member.h" #include "../../../src/chat/chat_core.h" +#include "../../../src/chat/chat_join_port.h" } /* ── Внутренний объект-приёмник в GUI-потоке ── */ @@ -29,6 +32,10 @@ public: Q_INVOKABLE void processPost(int eventType, QByteArray data); }; +static std::mutex join_mutex; +static struct chat_join_port* join_port = nullptr; +static std::atomic join_sequence{1}; + static GuiBridgeReceiver* g_receiver = nullptr; static gui_connect_result_fn g_connect_result_cb = nullptr; static gui_msg_received_fn g_msg_received_cb = nullptr; @@ -170,12 +177,13 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { } break; case GUI_EVT_CONNECT_RESULT: - if (dlen >= 20) { + if (dlen == 28) { uint64_t nodeId, channelId; int result; memcpy(&nodeId, d, 8); memcpy(&result, d + 8, 4); memcpy(&channelId, d + 12, 8); DEBUG_INFO(DEBUG_CATEGORY_MEMBER_SYNC, "gui_bridge: CONNECT_RESULT node=0x%016llx ch=%llu result=%d", (unsigned long long)nodeId, (unsigned long long)channelId, result); - if (g_connect_result_cb) g_connect_result_cb(nodeId, channelId, result); + uint64_t request; memcpy(&request, d + 20, 8); + if (g_connect_result_cb) g_connect_result_cb(nodeId, channelId, result, request); } else { DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "gui_bridge: CONNECT_RESULT data too short %d", dlen); } @@ -461,9 +469,28 @@ void gui_bridge_set_uasync(struct UASYNC* ua) { } void gui_bridge_set_inst(struct UTUN_INSTANCE* inst) { + struct chat_join_port* replacement = inst ? chat_sync_join_port(inst) : nullptr; + std::lock_guard guard(join_mutex); + chat_join_port_release(join_port); join_port = replacement; g_inst = inst; } +int gui_bridge_join_link(const char* link, size_t len, uint64_t* request) { + std::lock_guard guard(join_mutex); + uint64_t id = join_sequence.fetch_add(1); + int result = chat_join_port_submit(join_port, id, link, len); + if (!result) *request = id; + return result; +} + +int gui_bridge_offer_link(uint64_t channel, const char* link, size_t len, uint64_t* request) { + std::lock_guard guard(join_mutex); + uint64_t id = join_sequence.fetch_add(1); + int result = chat_join_port_offer(join_port, id, channel, link, len); + if (!result) *request = id; + return result; +} + struct UTUN_INSTANCE* gui_bridge_get_inst(void) { return g_inst; } diff --git a/tools/chatgui/transport/utun_node.cpp b/tools/chatgui/transport/utun_node.cpp index 8fabe45c..95dbc18b 100644 --- a/tools/chatgui/transport/utun_node.cpp +++ b/tools/chatgui/transport/utun_node.cpp @@ -286,6 +286,7 @@ void UtunNode::runLoop(std::promise startup) { uasync_poll(ua, 100); } + gui_bridge_set_inst(nullptr); uasync_mark_stopped(ua); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "utun_node: poll stopped ua=%p running=%d requested=%d", (void*)ua, uasync_is_running(ua), (int)m_stop);