From 0eb1d03a167e9aa189fe1d2ca07e8b338ecafe20 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 10 Oct 2026 11:27:39 -0700 Subject: [PATCH] Let room users manage their own return routes --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 8 +- docs/cli_commands.md | 3 + docs/halo_keymind_settings.md | 4 + docs/room_services.md | 59 +++ examples/simple_room_server/MyMesh.cpp | 223 ++++++++-- examples/simple_room_server/MyMesh.h | 5 +- src/helpers/RoomClientPathCommand.h | 67 +++ .../room_messages.cpp | 12 + test/fixtures/room_client_paths.cpp | 196 +++++++++ test/fixtures/room_topic_delivery/test.cpp | 77 +++- test/test_official_room_app_compatibility.py | 4 + test/test_room_client_paths.py | 394 ++++++++++++++++++ test/test_room_login_integration.py | 95 ++++- test/test_room_topic_delivery.py | 4 + 15 files changed, 1098 insertions(+), 54 deletions(-) create mode 100644 src/helpers/RoomClientPathCommand.h create mode 100644 test/fixtures/room_client_paths.cpp create mode 100644 test/test_room_client_paths.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 09e26051d..1bc7420ae 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -121,6 +121,7 @@ jobs: python3 -B test/test_room_post_permissions.py -v python3 -B test/test_room_login_authorization.py -v python3 -B test/test_room_login_integration.py -v + python3 -B test/test_room_client_paths.py -v python3 -B test/test_room_topic_store.py -v python3 -B test/test_room_topic_delivery.py -v python3 -B test/test_room_acl_persistence.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 487f87cd6..8c856f497 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -193,8 +193,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater | Yes | Yes | Yes | | Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | | Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater | Yes | Yes | Yes | -| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | -| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | +| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | +| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | | ACL | [`setperm `](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | | ACL | [`get acl [page]`](cli_commands.md#view-the-current-acl) | Repeater, room server, or sensor; admin over LoRa | Yes | Yes | Yes | | ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | @@ -369,8 +369,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | Yes | Yes | | Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater or [room user](room_services.md#your-own-return-routes), own remote-client context | Yes | Yes | Yes | Yes | Yes | | ACL | [`setperm `](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | Yes | Yes | | ACL | [`get acl [page]`](cli_commands.md#view-the-current-acl) | Repeater, room server, or sensor; admin over LoRa | Yes | Yes | Yes | Yes | Yes | | ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e936803d9..ffaf2146e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2452,6 +2452,9 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu **Notes:** - These commands require remote client context and update the caller's ACL entry. +- Room Server readers and writers may manage their own `outpath` and `altpath` + without administrator permission. See [room return routes](room_services.md#your-own-return-routes) + for the command list, session persistence and alternate delivery behavior. - `get outpath path` reports the reciprocal `PAYLOAD_TYPE_PATH` received after the caller's latest flood login without changing the selected output route. Because that packet is asynchronous, an immediate query can report diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 2ead795a8..33f079651 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -203,6 +203,10 @@ rows; an unpaged local serial query prints the full table. `outpath` and `altpath` apply to the current remote client ACL entry. They need remote client context, so they are not useful from the local serial CLI. +Room Server also supports these commands for readers and writers, restricted +to the caller's own routes. See [room return routes](room_services.md#your-own-return-routes) +for session persistence, alternate chat delivery and client requirements. + Set paths with comma-separated hop hashes. Each hop must be `2`, `4`, or `6` hex characters, and all hops in one path must use the same width. Hex input is case-insensitive. Replies use uppercase hex and retain the commas, so the value diff --git a/docs/room_services.md b/docs/room_services.md index 974ff4590..e5cc86916 100644 --- a/docs/room_services.md +++ b/docs/room_services.md @@ -101,6 +101,65 @@ Rejected posts do not enter the retry cache. Changing to a different identity creates a different user. These controls complement forwarding and airtime policies. +## Your own return routes + +Room readers, writers and administrators can use a private CLI request to +inspect or change the room's route back to their own radio: + +~~~text +get outpath +get outpath path +set outpath A1B2C3,D4E5F6 +set outpath path +set outpath direct +set outpath clear +set outpath flood +get altpath +set altpath 71CE82,BA09F0 +set altpath direct +set altpath clear +~~~ + +Each hop is 2, 4 or 6 hexadecimal characters; all hops in a path use the same +width. `direct` means zero repeaters. `clear` enables normal discovery again. +`outpath flood` forces flood replies until you change that selection. The +first command word is case-insensitive, and an optional Companion CLI prefix +is preserved in the reply. + +These commands always affect the authenticated requesting identity. They +cannot select another user's public key, change room settings, grant roles, +read passwords or run other administrative commands. Active public read-only +sessions can manage their own routes too. Bans still block access, and management +roles alone do not grant room-user command access. + +Commands require encrypted CLI message framing. Typing them as ordinary room +chat posts sends chat text instead. Use a CLI-capable client; this feature does +not grant administrator status or add a non-admin command screen to the mobile +app. No new protocol message type is required. + +`get outpath path` reports the reciprocal route observed after the most recent +flood login. It can return `> path pending` during its one-minute observation +window. The read does not change the selected route; ordinary room discovery +still learns a live route automatically. A route explicitly selected while +waiting is protected from the delayed login PATH. `set outpath path` selects +the observed route, or leaves the selection unchanged if none is available. + +When the primary and alternate routes are valid and different, chat, topic +updates and CLI/data replies send one encrypted copy along each route. The +primary owns ACK tracking, catch-up and retries; the alternate has no separate +retry loop. Identical routes send one copy. A missing packet for the alternate +does not prevent primary delivery. A flood primary does not send an alternate +copy; `altpath flood` also disables that secondary copy, so prefer +`set altpath clear` when removing it. Keep-alive and post ACKs retain their +existing primary-route behavior. + +Routes are saved for already-retained ACL entries, including explicit readers +and writers and password-created administrators. Ordinary guest-password and +public-reader sessions keep route selections only for the current session; +changing a route never makes their permissions permanent. Failed saves restore +the previous route and report an error. Repeating an unchanged selection or an +exact request retry does not cause another settings write. + ## Local Wi-Fi chat on ESP32 ESP32 Room Server images with WebConfig have an offline browser room page. diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index bf6c5e8b3..7ddad4715 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #if MESH_PACKET_LOGGING #include @@ -452,6 +454,134 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r } #endif +bool MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet, + unsigned long delay_millis, uint8_t path_hash_size) { + if (packet == NULL) return false; + if (client == NULL || !mesh::Packet::isValidPathLen(client->out_path_len)) { + return sendFloodReply(packet, delay_millis, path_hash_size); + } + + mesh::Packet* alternate = NULL; + if (mesh::Packet::isValidPathLen(client->alt_path_len) + && (client->alt_path_len != client->out_path_len + || memcmp(client->alt_path, client->out_path, + mesh::encodedClientPathByteLength(client->out_path_len)) != 0)) { + alternate = obtainNewPacket(); + if (alternate != NULL) *alternate = *packet; + } + const bool sent = sendDirect(packet, client->out_path, client->out_path_len, + delay_millis); + if (alternate != NULL) { + if (sent) { + // The primary owns retry and delivery state; the extra route is one copy. + const uint8_t retries = _prefs.direct_retry_enabled; + _prefs.direct_retry_enabled = 0; + sendDirect(alternate, client->alt_path, client->alt_path_len, delay_millis); + _prefs.direct_retry_enabled = retries; + } else { + releasePacket(alternate); + } + } + return sent; +} + +bool MyMesh::handleClientPathCommand(ClientInfo* client, char* command, char* reply) { + char* body = mesh::normalizeRoomClientPathCommand(command, reply); + const mesh::RoomClientPathCommand operation = mesh::classifyRoomClientPathCommand(body); + if (operation == mesh::RoomClientPathCommand::None) return false; + if (strlen(command) > 4 && command[2] == '|') reply += 3; + // Leading whitespace can precede the same single Companion prefix. + else { + const char* start = command; + while (*start == ' ' || *start == '\t') start++; + if (strlen(start) > 4 && start[2] == '|') reply += 3; + } + if (client == NULL) { + strcpy(reply, "Err - command needs remote client context"); + return true; + } + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + if (!(role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE + || role == PERM_ACL_ADMIN + || (role == PERM_ACL_GUEST && client->last_activity != 0))) { + strcpy(reply, "Err - room user permission required"); + return true; + } + const bool alternate = operation == mesh::RoomClientPathCommand::GetAlt + || operation == mesh::RoomClientPathCommand::SetAlt; + const bool observed = operation == mesh::RoomClientPathCommand::GetObserved + || operation == mesh::RoomClientPathCommand::SetObserved; + const bool setting = operation == mesh::RoomClientPathCommand::SetOut + || operation == mesh::RoomClientPathCommand::SetObserved + || operation == mesh::RoomClientPathCommand::SetAlt; + uint8_t* selected = alternate ? client->alt_path : client->out_path; + uint8_t* selected_length = alternate ? &client->alt_path_len : &client->out_path_len; + if (observed && mesh::isObservedClientPathPending(*client, futureMillis(0))) { + strcpy(reply, setting ? "Err - path pending" : "> path pending"); + return true; + } + if (!setting) { + mesh::formatRoomClientPathReply(observed ? client->observed_path : selected, + observed ? client->observed_path_len : *selected_length, reply, 157); + return true; + } + + uint8_t path[MAX_PATH_SIZE] = {}; + uint8_t length = OUT_PATH_UNKNOWN; + if (observed) { + length = client->observed_path_len; + if (!mesh::isValidObservedClientPathLength(length, sizeof(path))) { + strcpy(reply, "Err - no path received"); + return true; + } + memcpy(path, client->observed_path, mesh::observedClientPathByteLength(length)); + } else { + const char* spec = mesh::cli::skipRecentRepeaterSpaces(body + 11); + if (mesh::cli::terminalPathKeywordMatches(spec, "flood")) length = OUT_PATH_FORCE_FLOOD; + else if (mesh::cli::terminalPathKeywordMatches(spec, "none") + || mesh::cli::terminalPathKeywordMatches(spec, "-")) length = OUT_PATH_UNKNOWN; + else { + mesh::cli::TerminalPath parsed; + if (mesh::cli::parseTerminalPath(spec, path, sizeof(path), 63, parsed) + != mesh::cli::TerminalPathParseResult::Valid) { + strcpy(reply, "Err - invalid path"); + return true; + } + length = parsed.mode == mesh::cli::TerminalPathMode::Clear + ? OUT_PATH_UNKNOWN : parsed.encoded_len; + } + } + const size_t path_bytes = mesh::Packet::isValidPathLen(length) + ? mesh::encodedClientPathByteLength(length) : 0; + const bool same = length == *selected_length + && (path_bytes == 0 || memcmp(path, selected, path_bytes) == 0) + && (alternate || client->out_path_is_persistable); + if (!same) { + const uint8_t previous_length = *selected_length; + uint8_t previous[MAX_PATH_SIZE]; + memcpy(previous, selected, sizeof(previous)); + const bool was_persistable = client->out_path_is_persistable; + memcpy(selected, path, sizeof(path)); + *selected_length = length; + if (!alternate) client->out_path_is_persistable = true; + // A route never promotes a password-derived session into a saved ACL role. + // Only already-retained entries write through the room's existing filter. + if (saveFilter(client)) { + if (!acl.save(_fs, MyMesh::saveFilter)) { + memcpy(selected, previous, sizeof(previous)); + *selected_length = previous_length; + client->out_path_is_persistable = was_persistable; + strcpy(reply, "Err - path save failed"); + return true; + } + mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, contacts_save_failures); + } + } + if (!alternate && length == OUT_PATH_UNKNOWN) strcpy(reply, "> outpath cleared"); + else mesh::formatRoomClientPathReply(selected, *selected_length, reply, 157); + return true; +} + bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp, const mesh::Identity& author, const char* text, uint32_t topic_revision) { @@ -495,7 +625,7 @@ bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp, client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD); } } else { - sent = sendDirect(reply, client->out_path, client->out_path_len); + sent = sendClientReply(client, reply, 0, _prefs.path_hash_mode + 1); if (sent) { uint8_t path_hash_count = client->out_path_len & 63; @@ -1217,6 +1347,7 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m *client, client_existed, perm, PERM_ACL_ROLE_MASK, secret, sender_timestamp, getRTCClock()->getCurrentTime(), reset_out_path, OUT_PATH_UNKNOWN); + mesh::clearObservedClientPath(*client, OUT_PATH_UNKNOWN); client->extra.room.sync_since = sender_sync_since; client->extra.room.pending_ack = 0; client->extra.room.push_failures = 0; @@ -1252,7 +1383,9 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(sender, client->shared_secret, packet->path, packet->path_len, PAYLOAD_TYPE_RESPONSE, reply_data, 13); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + if (path && sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize())) { + mesh::beginObservedClientPath(*client, OUT_PATH_UNKNOWN, futureMillis(60000)); + } } else { mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, client->shared_secret, reply_data, 13); if (reply) { @@ -1344,6 +1477,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } #endif if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or new Post + // An inactive unassigned entry cannot create a reader session by sending + // rejected commands that would otherwise refresh last_activity below. + if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST + && client->last_activity == 0) return; uint32_t sender_timestamp; memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) uint8_t flags = (data[4] >> 2); // message attempt number, and other flags @@ -1406,7 +1543,22 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, client->last_timestamp = sender_timestamp; } - if (cached_retry) { + // Cached results must obey current permissions too. A former admin may + // recover an own-route reply, but cannot replay cached privileged output. + char normalized[mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1]; + StrHelper::strncpy(normalized, text, sizeof(normalized)); + char prefix[4] = {}; + const char* body = mesh::normalizeRoomClientPathCommand(normalized, prefix); + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + const bool own_path_allowed = + mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None + && (role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE + || role == PERM_ACL_ADMIN + || (role == PERM_ACL_GUEST && client->last_activity != 0)); + if (cached_retry && !client->isAdmin() && !own_path_allowed) { + strcpy((char*)&temp[5], "Err - admin permission required"); + temp[4] = (TXT_TYPE_CLI_DATA << 2); + } else if (cached_retry) { MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply"); size_t cached_len = strlen(cached_response); if (cached_len > mesh::RemoteCliReplyCache::MAX_REPLY_TEXT) { @@ -1418,21 +1570,21 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } else if (repeated_timestamp) { MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply"); return; - } else if (client->isAdmin()) { - handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], - i, packet->getPathHashSize()); + } else { + if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5])) { + if (client->isAdmin()) { + handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], + i, packet->getPathHashSize()); + } else { + strcpy((char*)&temp[5], "Err - admin permission required"); + } + } temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT] = 0; if (temp[5] == 0) strcpy((char*)&temp[5], "OK"); remote_cli_reply_cache.remember(client->id.pub_key, request_id, command_fingerprint, (char*)&temp[5]); temp[4] = (TXT_TYPE_CLI_DATA << 2); - } else { - const char* error = "Err - admin permission required"; - strcpy((char*)&temp[5], error); - remote_cli_reply_cache.remember(client->id.pub_key, request_id, - command_fingerprint, error); - temp[4] = (TXT_TYPE_CLI_DATA << 2); } // CLI_DATA replies are the result signal; no separate ACK is expected. } @@ -1482,13 +1634,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + reply_text_len); if (reply) { - if (!mesh::Packet::isValidPathLen(client->out_path_len)) { - sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, + sendClientReply(client, reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - sendDirect(reply, client->out_path, client->out_path_len, - delay_millis + SERVER_RESPONSE_DELAY); - } } } } else if (type == PAYLOAD_TYPE_REQ && len >= 5) { @@ -1576,11 +1723,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } else { mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); if (reply) { - if (mesh::Packet::isValidPathLen(client->out_path_len)) { // we have an out_path, so send DIRECT - sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - } else { - sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } + sendClientReply(client, reply, SERVER_RESPONSE_DELAY, + packet->getPathHashSize()); } } } @@ -1598,15 +1742,22 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); auto client = acl.getClientByIdx(i); if (!room_access.allowsIdentity(client->id.pub_key)) return false; + const bool captured_login_path = packet->isRouteDirect() && extra_type == 0x0F + && mesh::captureObservedClientPath(*client, path, path_len, futureMillis(0)); // PATH has no durable replay freshness signal. Keep it RAM-only and do not // let a replay replace an operator-selected force-flood route. - const bool persistence_allowed = mesh::clientPathPersistenceAllowed( - MyMesh::saveFilter(client), false /* no durable replay proof */); - const mesh::ClientPathUpdateResult path_update = - mesh::applyReceivedClientPath( - *client, path, path_len, persistence_allowed, - OUT_PATH_FORCE_FLOOD); - (void)path_update; + // Preserve normal room-app discovery, but do not let the delayed login + // PATH overwrite a route the user explicitly selected in the meantime. + if (!captured_login_path || !client->out_path_is_persistable + || !mesh::Packet::isValidPathLen(client->out_path_len)) { + const bool persistence_allowed = mesh::clientPathPersistenceAllowed( + MyMesh::saveFilter(client), false /* no durable replay proof */); + const mesh::ClientPathUpdateResult path_update = + mesh::applyReceivedClientPath( + *client, path, path_len, persistence_allowed, + OUT_PATH_FORCE_FLOOD); + (void)path_update; + } client->last_activity = getRTCClock()->getCurrentTime(); } else { MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); @@ -2100,7 +2251,7 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) { return false; } -void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { +bool MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { TransportKey req_scope; bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); bool req_scope_known = recv_pkt_region != NULL && !is_wildcard @@ -2108,17 +2259,15 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; + return sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request case mesh::REPLY_SCOPE_DEFAULT: // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; + return sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; + return sendFlood(packet, delay_millis, path_hash_size); // send un-scoped } + return false; } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index d942d4bde..6003a51c3 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -466,7 +466,7 @@ protected: } #endif - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + bool sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); #if MESH_ENABLE_TELEMETRY_HISTORY bool handleTelemetryHistoryCommand(const char* command, char* reply); void sampleTelemetryHistory(); @@ -598,6 +598,9 @@ public: void handleCommand(uint32_t sender_timestamp, char* command, char* reply, int gpio_client_index = -1, uint8_t gpio_path_hash_size = 1); + bool handleClientPathCommand(ClientInfo* client, char* command, char* reply); + bool sendClientReply(ClientInfo* client, mesh::Packet* packet, + unsigned long delay_millis, uint8_t path_hash_size); bool _wireless_usb_command = false; void handleUsbCommand(char* command, char* reply) { _wireless_usb_command = true; diff --git a/src/helpers/RoomClientPathCommand.h b/src/helpers/RoomClientPathCommand.h new file mode 100644 index 000000000..55276b1a0 --- /dev/null +++ b/src/helpers/RoomClientPathCommand.h @@ -0,0 +1,67 @@ +#pragma once + +#include "CLICommandUtils.h" +#include "ClientPathPersistence.h" + +namespace mesh { + +enum class RoomClientPathCommand : uint8_t { + None, GetOut, GetObserved, GetAlt, SetOut, SetObserved, SetAlt, +}; + +// Normalize a private CLI request, not a room-chat post. A single Companion +// correlation prefix is echoed; nested prefixes never enter the allowlist. +inline char* normalizeRoomClientPathCommand(char* command, char* reply) { + char* end = command + strlen(command); + while (end > command && (end[-1] == ' ' || end[-1] == '\t' + || end[-1] == '\r' || end[-1] == '\n')) *--end = 0; + while (*command == ' ' || *command == '\t') command++; + if (strlen(command) > 4 && command[2] == '|') { + memcpy(reply, command, 3); + command += 3; + } + while (*command == ' ' || *command == '\t') command++; + cli::normalizeCommandVerb(command); + return command; +} + +inline RoomClientPathCommand classifyRoomClientPathCommand(const char* command) { + if (strcmp(command, "get outpath") == 0) return RoomClientPathCommand::GetOut; + if (strcmp(command, "get outpath path") == 0) return RoomClientPathCommand::GetObserved; + if (strcmp(command, "get altpath") == 0) return RoomClientPathCommand::GetAlt; + if (strcmp(command, "set outpath path") == 0) return RoomClientPathCommand::SetObserved; + if (strcmp(command, "set outpath") == 0 || strncmp(command, "set outpath ", 12) == 0) + return RoomClientPathCommand::SetOut; + if (strcmp(command, "set altpath") == 0 || strncmp(command, "set altpath ", 12) == 0) + return RoomClientPathCommand::SetAlt; + return RoomClientPathCommand::None; +} + +inline void formatRoomClientPathReply(const uint8_t* path, uint8_t length, + char* reply, size_t capacity) { + if (length == 0xfe) { snprintf(reply, capacity, "> flood"); return; } + if (length == 0xff) { snprintf(reply, capacity, "> unknown"); return; } + if (!isValidEncodedClientPathLength(length, 64)) { + snprintf(reply, capacity, "> invalid"); return; + } + const size_t hops = length & 63; + if (hops == 0) { snprintf(reply, capacity, "> direct"); return; } + const size_t width = (length >> 6) + 1; + const size_t bytes = hops * width; + // Compact fallback remains complete when comma separators would not fit. + const bool separated = bytes * 2 + hops + 2 <= capacity; + if (bytes * 2 + 3 > capacity) { + snprintf(reply, capacity, "> path too long"); return; + } + static const char hex[] = "0123456789ABCDEF"; + size_t position = 0; + reply[position++] = '>'; reply[position++] = ' '; + for (size_t i = 0; i < bytes; i++) { + if (separated && i != 0 && i % width == 0) reply[position++] = ','; + reply[position++] = hex[path[i] >> 4]; + reply[position++] = hex[path[i] & 15]; + } + reply[position] = 0; +} + +} // namespace mesh diff --git a/test/fixtures/official_room_app_compatibility/room_messages.cpp b/test/fixtures/official_room_app_compatibility/room_messages.cpp index e94c8d7c7..d5827101b 100644 --- a/test/fixtures/official_room_app_compatibility/room_messages.cpp +++ b/test/fixtures/official_room_app_compatibility/room_messages.cpp @@ -33,6 +33,16 @@ static void emit(MyMesh& room, const char* group, unsigned index, int version, b WirePresentation app{version}; app.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, sent.timestamp, sent.author, 4, sent.text.c_str(), false, 0); + if (strcmp(group, "ordered") == 0) { + assert(room.sent.size() == 2 * (index + 1)); + const auto& primary = room.sent[room.sent.size() - 2]; + assert(primary.path[0] == 0xA1 && sent.path[0] == 0xB2); + assert(primary.retry_enabled && !sent.retry_enabled); + WirePresentation primary_app{version}; + primary_app.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, primary.timestamp, + primary.author, 4, primary.text.c_str(), false, 0); + assert(primary_app.frame == app.frame); + } assert(app.frame.size() == sent.text.size() + (version >= 3 ? 20 : 17)); printf("POST:%s:%u:%d:%d:%u:", group, index, version, int(flood), sent.timestamp); hex(sent.author, 4); printf(":"); @@ -44,6 +54,8 @@ int main() { { MyMesh room; room.topic("Welcome"); auto& client = room.acl.clients[0]; client.extra.room.sync_since = 50; + client.out_path_len = client.alt_path_len = 1; + client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2; room.post(0, 100, "old-one"); room.post(1, 200, "old-two"); room.post(2, 1100, "newer"); room.clock.now = 1200; room.tick(6000); assert(room.sent.back().text == "old-one"); diff --git a/test/fixtures/room_client_paths.cpp b/test/fixtures/room_client_paths.cpp new file mode 100644 index 000000000..a610bf6f9 --- /dev/null +++ b/test/fixtures/room_client_paths.cpp @@ -0,0 +1,196 @@ +#include +#include +#include +#include +#include +#include +#include "filesystem.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +@CONSTANTS@ +@STR_HELPER@ +#define MESH_CLIENT_REPEATER_ONLY 0 +namespace mesh { +struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; }; +struct Utils { + static void sha256(uint8_t* out, size_t n, const uint8_t* left, size_t ln, + const uint8_t* right, size_t rn) { + memset(out, 0, n); + for (size_t i = 0; i < ln; ++i) out[i % n] ^= left[i]; + for (size_t i = 0; i < rn; ++i) out[i % n] ^= right[i]; + } +}; +@PACKET_METHODS@ +} +@CLIENT_INFO@; + +struct FakeACL { + ClientInfo clients[2]{}; + ClientInfo durable[2]{}; + bool retained[2]{}; + bool save_ok = true; + unsigned saves = 0; + int getNumClients() const { return 2; } + ClientInfo* getClientByIdx(int i) { assert(i >= 0 && i < 2); return &clients[i]; } + bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) { + ++saves; + assert(fs != nullptr && filter != nullptr); + if (!save_ok) return false; + for (unsigned i = 0; i < 2; ++i) { + retained[i] = filter(&clients[i]); + if (retained[i]) durable[i] = clients[i]; + } + return true; + } +}; +struct FakeClock { + uint32_t now = 1000; + uint32_t serial = 0; + uint32_t getCurrentTime() const { return now; } + uint32_t getCurrentTimeUnique() { return now + ++serial; } +}; +struct FakeRng { + void random(uint8_t* data, size_t n) { memset(data, 0xB5, n); } +}; +static uint32_t ticks = 100; +static uint32_t millis() { return ticks; } +struct Sent { + mesh::Packet packet; + uint8_t path[MAX_PATH_SIZE]{}; + uint8_t path_len = 0; + unsigned long delay = 0; + bool direct = true; + bool retry_enabled = false; +}; +struct MyMesh { + FakeACL acl; + MemoryFS policy_fs; + MemoryFS* _fs = &policy_fs; + mesh::RoomAccessPolicy room_access; + mesh::RemoteCliReplyCache remote_cli_reply_cache; + mesh::LogicalMessageCache recent_room_posts; + mesh::LogicalMessageCache recent_room_polls; + int matching_peer_indexes[2] = {0, 1}; + unsigned long dirty_contacts_expiry = 0; + uint8_t contacts_save_failures = 0; + FakeClock clock; + FakeRng rng; + uint8_t reply_data[MAX_PACKET_PAYLOAD]{}; + mesh::Packet pool[64]; + unsigned used = 0, allocations = 0, releases = 0, fail_allocation = UINT32_MAX; + bool send_ok = true; + unsigned general_commands = 0, posts = 0, retry_replacements = 0, _num_post_pushes = 0; + uint32_t retry_timestamp = 0; + mesh::Packet retry_packet; + std::vector sent; + uint16_t default_scope = 0; + struct Prefs { + uint8_t direct_retry_enabled = 1; + uint8_t path_hash_mode = 0; + } _prefs; + MyMesh() { + metadata_filesystem = &policy_fs; + assert(room_access.load(&policy_fs)); + for (unsigned i = 0; i < 2; ++i) { + ClientInfo& c = acl.clients[i]; + memset(&c, 0, sizeof(c)); + memset(c.id.pub_key, i + 1, PUB_KEY_SIZE); + memset(c.shared_secret, i + 0x40, PUB_KEY_SIZE); + c.permissions = PERM_ACL_READ_WRITE; + c.out_path_len = 1; + c.out_path[0] = i + 0xA1; + c.alt_path_len = OUT_PATH_UNKNOWN; + c.observed_path_len = OUT_PATH_UNKNOWN; + c.last_timestamp = 99; + } + } + FakeClock* getRTCClock() { return &clock; } + FakeRng* getRNG() { return &rng; } + unsigned long futureMillis(unsigned long delay) { return ticks + delay; } + mesh::Packet* obtainNewPacket() { + if (++allocations == fail_allocation) return nullptr; + assert(used < 64); + return &pool[used++]; + } + void releasePacket(mesh::Packet* p) { assert(p != nullptr); ++releases; } + mesh::Packet* createDatagram(uint8_t type, const mesh::Identity&, const uint8_t*, + const uint8_t* data, size_t n) { + auto p = obtainNewPacket(); + if (p) { + p->header = type << PH_TYPE_SHIFT; + p->payload_len = n; + memcpy(p->payload, data, n); + } + return p; + } + mesh::Packet* createPathReturn(const mesh::Identity& id, const uint8_t* secret, + const uint8_t*, uint8_t, uint8_t type, const uint8_t* data, size_t n) { + return createDatagram(type, id, secret, data, n); + } + mesh::Packet* createAck(uint32_t ack) { + mesh::Identity empty{}; uint8_t secret[PUB_KEY_SIZE]{}; + return createDatagram(PAYLOAD_TYPE_ACK, empty, secret, (const uint8_t*)&ack, 4); + } + mesh::Packet* createMultiAck(uint32_t ack, uint8_t) { return createAck(ack); } + bool sendDirect(mesh::Packet* p, const uint8_t* path, uint8_t n, unsigned long delay = 0) { + assert(p && mesh::Packet::isValidPathLen(n)); + Sent value; value.packet = *p; value.path_len = n; value.delay = delay; + value.retry_enabled = _prefs.direct_retry_enabled != 0; + memcpy(value.path, path, mesh::encodedClientPathByteLength(n)); + sent.push_back(value); + return send_ok; + } + bool sendFloodReply(mesh::Packet* p, unsigned long delay, uint8_t hash_size) { + Sent value; value.packet = *p; value.delay = delay; value.direct = false; + value.path_len = (hash_size - 1) << 6; + sent.push_back(value); + return send_ok; + } + bool sendFloodScoped(uint16_t, mesh::Packet* p, unsigned long delay, uint8_t hash_size) { + return sendFloodReply(p, delay, hash_size); + } + void replaceActiveMessageRetries(mesh::Packet* p, const uint8_t*, uint32_t timestamp) { + ++retry_replacements; retry_packet = *p; retry_timestamp = timestamp; + } + void handleCommand(uint32_t, char*, char* reply, int, uint8_t) { + ++general_commands; strcpy(reply, "OK admin"); + } + bool addPost(ClientInfo*, const char*) { ++posts; return true; } + void serviceRoomQuotas(); + uint8_t getUnsyncedCount(ClientInfo*) { return 0; } + int getExtraAckTransmitCount() { return 0; } + int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, size_t) { return 0; } + static bool saveFilter(ClientInfo*); + bool handleClientPathCommand(ClientInfo*, char*, char*); + bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t); + bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t); + void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t); + void command(const char* text, uint32_t timestamp = 100, unsigned sender = 0, + uint32_t logical = 0, uint8_t flags = TXT_TYPE_CLI_COMMAND) { + std::vector data(5 + strlen(text) + mesh::RemoteCliRequest::EXTENSION_SIZE + 1); + memcpy(data.data(), ×tamp, 4); data[4] = flags << 2; + memcpy(data.data() + 5, text, strlen(text)); + size_t length = 5 + strlen(text); + if (logical) length = mesh::RemoteCliRequest::append(data.data(), data.size(), 5, strlen(text), logical); + mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT; + onPeerDataRecv(&packet, PAYLOAD_TYPE_TXT_MSG, sender, acl.clients[sender].shared_secret, + data.data(), length); + } + std::string reply() const { + if (sent.empty()) return ""; + const auto& p = sent.back().packet; + return std::string((const char*)p.payload + 5, p.payload_len - 5); + } +}; +@PRODUCTION_METHODS@ +@TEST_CASES@ diff --git a/test/fixtures/room_topic_delivery/test.cpp b/test/fixtures/room_topic_delivery/test.cpp index d487ee308..b64b6b44d 100644 --- a/test/fixtures/room_topic_delivery/test.cpp +++ b/test/fixtures/room_topic_delivery/test.cpp @@ -6,6 +6,7 @@ #include #include "../room_history_store/filesystem.h" #include +#include #define PUB_KEY_SIZE 32 #define MAX_PATH_SIZE 64 @@ -37,7 +38,9 @@ struct Identity { }; struct Packet { bool radio_reply = false; - static bool isValidPathLen(uint8_t path) { return (path & 63) <= 20 && path < 0xc0; } + uint8_t payload[MAX_PACKET_PAYLOAD] = {}; + size_t payload_len = 0; + static bool isValidPathLen(uint8_t path); }; struct Utils { static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na, @@ -78,6 +81,9 @@ struct Sent { uint32_t timestamp; std::string text; uint8_t author[4]; + uint8_t path[MAX_PATH_SIZE] = {}; + uint8_t path_len = 0; + bool direct = false, retry_enabled = false; }; struct MyMesh { ACL acl; @@ -89,8 +95,10 @@ struct MyMesh { mesh::Store store; mesh::Store* _fs = &store; mesh::Packet packet; + mesh::Packet alternate_packet; std::vector sent; - bool pool_available = true, queue_available = true; + bool pool_available = true, queue_available = true, alternate_available = true; + unsigned releases = 0; uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; unsigned long next_push = 0, room_topic_ready_at = 0; bool room_topic_ready = true; @@ -101,7 +109,7 @@ struct MyMesh { PostInfo posts[MAX_UNSYNCED_POSTS] = {}; uint32_t post_ready_at[MAX_UNSYNCED_POSTS] = {}; uint32_t post_ready_mask = UINT32_MAX; - struct { uint8_t path_hash_mode = 0; } _prefs; + struct { uint8_t path_hash_mode = 0, direct_retry_enabled = 1; } _prefs; int default_scope = 0; MyMesh() { metadata_filesystem = &policy_fs; @@ -109,6 +117,7 @@ struct MyMesh { self_id.pub_key[0] = 77; acl.clients[0].id.pub_key[0] = 1; acl.clients[0].last_activity = 1000; + for (auto& client : acl.clients) client.alt_path_len = OUT_PATH_UNKNOWN; } Clock* getRTCClock() { return &clock; } Random* getRNG() { return &rng; } @@ -117,24 +126,37 @@ struct MyMesh { assert(type == PAYLOAD_TYPE_TXT_MSG && len >= 9 && len <= 160); if (!pool_available) return nullptr; assert((data[4] >> 2) == TXT_TYPE_SIGNED_PLAIN); + memcpy(packet.payload, data, len); + packet.payload_len = len; return &packet; } + mesh::Packet* obtainNewPacket() { return alternate_available ? &alternate_packet : nullptr; } + void releasePacket(mesh::Packet* out) { assert(out == &alternate_packet); ++releases; } bool queue(mesh::Packet* out) { assert(out->radio_reply); if (!queue_available) return false; Sent entry; - memcpy(&entry.timestamp, reply_data, 4); - memcpy(entry.author, reply_data + 5, 4); - entry.text = std::string(reinterpret_cast(reply_data + 9)); - // The production payload isn't terminated; test messages below are fixed - // lengths or the whole buffer is cleared before each send by tick(). + memcpy(&entry.timestamp, out->payload, 4); + memcpy(entry.author, out->payload + 5, 4); + entry.text = std::string(reinterpret_cast(out->payload + 9), out->payload_len - 9); sent.push_back(entry); return true; } bool sendFloodScoped(int, mesh::Packet* out, unsigned long, uint8_t) { return queue(out); } - bool sendDirect(mesh::Packet* out, const uint8_t*, uint8_t) { return queue(out); } + bool sendFloodReply(mesh::Packet* out, unsigned long delay, uint8_t width) { + return sendFloodScoped(default_scope, out, delay, width); + } + bool sendDirect(mesh::Packet* out, const uint8_t* path, uint8_t length, unsigned long = 0) { + if (!queue(out)) return false; + auto& entry = sent.back(); + entry.direct = true; entry.path_len = length; + entry.retry_enabled = _prefs.direct_retry_enabled != 0; + memcpy(entry.path, path, mesh::encodedClientPathByteLength(length)); + return true; + } void replaceActiveMessageRetries(mesh::Packet*, const uint8_t*, uint32_t) {} bool pushPostToClient(ClientInfo*, PostInfo&); + bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t); bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t = 0); bool processAck(const uint8_t*); void activateRoomTopic(); @@ -299,5 +321,42 @@ int main() { assert(memcmp(&ban_before, &banned, sizeof(banned)) == 0); assert(allowed.extra.room.pending_ack == 0 && allowed.extra.room.sync_since == 300); } + { + MyMesh m; auto& client = m.acl.clients[0]; + client.out_path_len = client.alt_path_len = 1; + client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2; + m.topic("two-route topic"); m.post(0, 900, "two-route post"); + m.tick(6000); + assert(m.sent.size() == 2 && m.sent[0].text == "two-route post"); + assert(m.sent[1].text == m.sent[0].text && m.sent[0].timestamp == m.sent[1].timestamp); + assert(m.sent[0].path[0] == 0xA1 && m.sent[1].path[0] == 0xB2); + assert(m.sent[0].retry_enabled && !m.sent[1].retry_enabled); + assert(m._prefs.direct_retry_enabled == 1 && m._num_post_pushes == 1); + assert(client.extra.room.ack_timeout == futureMillis(PUSH_TIMEOUT_BASE + 2 * PUSH_ACK_TIMEOUT_FACTOR)); + const uint32_t post_ack = client.extra.room.pending_ack; + m.ack(); assert(client.extra.room.sync_since == 900); + assert(!m.processAck(reinterpret_cast(&post_ack))); + m.tick(); assert(m.sent.size() == 4 && m.sent[2].text == "two-route topic"); + assert(m.sent[3].text == m.sent[2].text && m.sent[2].timestamp == m.sent[3].timestamp); + assert(m._num_post_pushes == 2 && client.extra.room.sync_since == 900); + m.ack(); assert(client.extra.room.sync_since == 900); + assert(client.extra.room.topic_seen_revision == m.room_topic_revision); + } + for (bool alternate_available : {false, true}) { + MyMesh m; auto& client = m.acl.clients[0]; + client.out_path_len = client.alt_path_len = 1; + client.out_path[0] = 0xA1; client.alt_path[0] = 0xB2; + m.alternate_available = alternate_available; + m.queue_available = !alternate_available; + m.topic("admission"); m.tick(6000); + if (alternate_available) { + assert(m.sent.empty() && m.releases == 1 && m._num_post_pushes == 0); + assert(client.extra.room.pending_ack == 0); + } else { + assert(m.sent.size() == 1 && m.sent[0].retry_enabled && m._num_post_pushes == 1); + m.ack(); + } + assert(m._prefs.direct_retry_enabled == 1); + } puts("room topic delivery regressions passed"); } diff --git a/test/test_official_room_app_compatibility.py b/test/test_official_room_app_compatibility.py index 5e1a7a2de..a5d2e44fd 100644 --- a/test/test_official_room_app_compatibility.py +++ b/test/test_official_room_app_compatibility.py @@ -33,9 +33,13 @@ def room_outputs(work, compiler): state += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n" (work / "state.inc").write_text(state, encoding="ascii") definitions = "\n".join(extract_braced(source, signature) for signature in ( + "bool MyMesh::sendClientReply(", "bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(", "bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(", "bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush(")) + packet = (ROOT / "src/Packet.cpp").read_text() + definitions = "namespace mesh {\n" + extract_braced( + packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions (work / "production.inc").write_text(definitions, encoding="ascii") fixture = (ROOT / "test/fixtures/room_topic_delivery/test.cpp").read_text() fixture = fixture[:fixture.index("int main()")] diff --git a/test/test_room_client_paths.py b/test/test_room_client_paths.py new file mode 100644 index 000000000..74d90b92a --- /dev/null +++ b/test/test_room_client_paths.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Execute room per-client path commands and delivery against production bodies.""" + +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/room_client_paths.cpp" + +CASES = r''' +static void roleCases() { + for (unsigned permissions = 0; permissions < 256; ++permissions) { + for (bool logged_in : {false, true}) { + MyMesh mesh; + auto& own = mesh.acl.clients[0]; + own.permissions = permissions; + own.last_activity = logged_in ? 800 : 0; + const ClientInfo other = mesh.acl.clients[1]; + const uint8_t role = permissions & PERM_ACL_ROLE_MASK; + const bool allowed = role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE + || role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && logged_in); + mesh.command("set altpath B1,B2"); + assert(mesh.general_commands == 0); + assert(memcmp(&mesh.acl.clients[1], &other, sizeof(other)) == 0); + assert(own.permissions == permissions && !own.permissions_are_explicit); + if (allowed) { + assert(own.alt_path_len == 2 && own.alt_path[0] == 0xB1 && own.alt_path[1] == 0xB2); + assert(mesh.reply() == "> B1,B2"); + assert(mesh.acl.saves == (role == PERM_ACL_ADMIN ? 1U : 0U)); + } else { + assert(own.alt_path_len == OUT_PATH_UNKNOWN && mesh.acl.saves == 0); + if (role == PERM_ACL_GUEST) { + assert(mesh.sent.empty() && own.last_timestamp == 99 && own.last_activity == 0); + mesh.command("set altpath C1", 101); + assert(own.alt_path_len == OUT_PATH_UNKNOWN && own.last_activity == 0); + assert(mesh.sent.empty() && own.last_timestamp == 99); + } else assert(mesh.reply().find("Err") != std::string::npos); + } + } + } + MyMesh mesh; + char command[] = "get outpath", reply[180]{}; + assert(mesh.handleClientPathCommand(nullptr, command, reply)); + assert(strstr(reply, "Err") && mesh.acl.saves == 0); + puts("256 permission combinations and live reader roles remain self-only passed"); +} + +static void exactAllowlist() { + const char* rejected[] = { + "get prv.key", "erase", "set password owned", "set radio 915,500,5,8", + "get outpathx", "get outpath path extra", "get altpath path", "set outpathx direct", + "set altpath path", "get outpath;erase", "set outpath A1;erase", + "AA|BB|set outpath direct", "set outpath direct;erase", "set altpath B1,,B2", + "set outpath B1,", "set outpath B1,1234", "set outpath +B", "set outpath", + }; + for (const char* text : rejected) { + MyMesh mesh; + const ClientInfo before = mesh.acl.clients[0], other = mesh.acl.clients[1]; + mesh.command(text); + const auto& own = mesh.acl.clients[0]; + assert(mesh.general_commands == 0 && mesh.acl.saves == 0); + assert(own.permissions == before.permissions && own.out_path_len == before.out_path_len); + assert(memcmp(own.out_path, before.out_path, MAX_PATH_SIZE) == 0); + assert(own.alt_path_len == before.alt_path_len); + assert(memcmp(&mesh.acl.clients[1], &other, sizeof(other)) == 0); + assert(mesh.reply().find("Err") != std::string::npos); + } + MyMesh normalized; + normalized.command(" AA| SET outpath B1,B2 \t\r\n"); + assert(normalized.acl.clients[0].out_path_len == 2); + assert(normalized.reply() == "AA|> B1,B2" && normalized.general_commands == 0); + MyMesh chat; + chat.command("set outpath B1", 100, 0, 0, TXT_TYPE_PLAIN); + assert(chat.posts == 1 && chat.acl.clients[0].out_path[0] == 0xA1); + MyMesh banned; + assert(banned.room_access.addBan(&banned.policy_fs, banned.acl.clients[0].id.pub_key) + == mesh::RoomAccessPolicy::BanResult::Saved); + banned.command("set outpath B1"); + assert(banned.sent.empty() && banned.acl.clients[0].out_path[0] == 0xA1); + puts("exact allowlist, correlation normalization, chat isolation, and ban gates passed"); +} + +static void pathFormats() { + struct Case { const char* command; uint8_t length; const char* response; }; + const Case cases[] = { + {"set outpath B1,B2", 2, "> B1,B2"}, + {"set outpath b100, b200", 0x42, "> B100,B200"}, + {"set outpath B10011\tB20022", 0x82, "> B10011,B20022"}, + {"set outpath direct", 0, "> direct"}, + {"set outpath clear", OUT_PATH_UNKNOWN, "> outpath cleared"}, + {"set outpath flood", OUT_PATH_FORCE_FLOOD, "> flood"}, + {"set altpath direct", 0, "> direct"}, + {"set altpath clear", OUT_PATH_UNKNOWN, "> unknown"}, + }; + for (const auto& c : cases) { + MyMesh mesh; mesh.command(c.command); + const bool alt = strstr(c.command, "altpath") != nullptr; + assert((alt ? mesh.acl.clients[0].alt_path_len : mesh.acl.clients[0].out_path_len) == c.length); + assert(mesh.reply() == c.response); + assert(mesh.acl.saves == 0 && !mesh.acl.clients[0].permissions_are_explicit); + } + MyMesh observed; + auto& client = observed.acl.clients[0]; + mesh::beginObservedClientPath(client, OUT_PATH_UNKNOWN, ticks + 50); + observed.command("get outpath path"); assert(observed.reply() == "> path pending"); + observed.command("set outpath path", 101); assert(observed.reply().find("pending") != std::string::npos); + const uint8_t route[] = {0x12, 0x34, 0x56, 0x78}; + assert(mesh::captureObservedClientPath(client, route, 0x42, ticks)); + observed.command("get outpath path", 102); assert(observed.reply() == "> 1234,5678"); + assert(client.out_path_len == 1 && client.out_path[0] == 0xA1); + observed.command("set outpath path", 103); + assert(client.out_path_len == 0x42 && memcmp(client.out_path, route, sizeof(route)) == 0); + MyMesh unavailable; + unavailable.command("set outpath path"); assert(unavailable.reply().find("no path") != std::string::npos); + assert(unavailable.acl.clients[0].out_path[0] == 0xA1); + uint8_t path[MAX_PATH_SIZE]; memset(path, 0xAB, sizeof(path)); + for (unsigned length = 0; length < 256; ++length) { + for (size_t capacity = 0; capacity <= 180; ++capacity) { + uint8_t bounded[184]; memset(bounded, 0xCC, sizeof(bounded)); + mesh::formatRoomClientPathReply(path, length, (char*)bounded + 2, capacity); + assert(bounded[0] == 0xCC && bounded[1] == 0xCC); + for (size_t i = 2 + capacity; i < sizeof(bounded); ++i) assert(bounded[i] == 0xCC); + if (capacity) assert(memchr(bounded + 2, 0, capacity) != nullptr); + } + } + puts("one-to-three byte paths, route sentinels, observed paths, and bounded formatting passed"); +} + +static void persistenceCases() { + for (uint8_t role : {uint8_t(PERM_ACL_GUEST), uint8_t(PERM_ACL_READ_ONLY), + uint8_t(PERM_ACL_READ_WRITE), uint8_t(PERM_ACL_ADMIN)}) { + for (bool explicit_role : {false, true}) { + MyMesh mesh; auto& own = mesh.acl.clients[0]; + own.permissions = role; own.permissions_are_explicit = explicit_role; own.last_activity = 900; + const uint8_t old_permissions = own.permissions; + mesh.command("set outpath B1"); + const bool durable = explicit_role || role == PERM_ACL_ADMIN; + assert(mesh.acl.saves == unsigned(durable)); + assert(own.out_path_is_persistable); + assert(own.permissions_are_explicit == explicit_role && own.permissions == old_permissions); + if (durable) { + assert(mesh.acl.retained[0] && !mesh.acl.retained[1]); + assert(mesh.acl.durable[0].out_path[0] == 0xB1); + } + mesh.command("set outpath B1", 101); + assert(mesh.acl.saves == unsigned(durable)); // A no-op does not wear flash. + mesh.command("set altpath C1", 102); + assert(mesh.acl.saves == (durable ? 2U : 0U)); + mesh.command("set altpath C1", 103); + assert(mesh.acl.saves == (durable ? 2U : 0U)); + } + } + for (const char* command_text : {"set outpath B1", "set altpath C1"}) { + MyMesh mesh; auto& own = mesh.acl.clients[0]; + own.permissions = PERM_ACL_ADMIN; own.out_path_is_persistable = true; + const ClientInfo before = own; + mesh.acl.save_ok = false; mesh.dirty_contacts_expiry = 50000; mesh.contacts_save_failures = 3; + char command[80], reply[180]{}; strcpy(command, command_text); + assert(mesh.handleClientPathCommand(&own, command, reply)); + assert(strstr(reply, "save failed") && mesh.acl.saves == 1); + assert(memcmp(&own, &before, sizeof(own)) == 0); + assert(mesh.dirty_contacts_expiry == 50000 && mesh.contacts_save_failures == 3); + } + puts("transient roles stay transient, filtered durability, no-op writes, and save rollback passed"); +} + +static void retryCases() { + MyMesh mesh; auto& own = mesh.acl.clients[0]; + own.permissions_are_explicit = true; + mesh.command("set outpath B1,B2", 100, 0, 777); + assert(mesh.acl.saves == 1 && mesh.reply() == "> B1,B2"); + mesh.command("set outpath C1", 101); + assert(mesh.acl.saves == 2 && own.out_path_len == 1 && own.out_path[0] == 0xC1); + mesh.command("set outpath B1,B2", 102, 0, 777); + assert(mesh.acl.saves == 2 && own.out_path_len == 1 && own.out_path[0] == 0xC1); + assert(mesh.reply() == "> B1,B2"); // Recover the original result without repeating mutation. + const size_t before_reject = mesh.sent.size(); + mesh.command("set outpath D1", 102); // Equal-time different request is not fresh. + mesh.command("set outpath D1", 99); // Old uncached request is not fresh. + assert(mesh.acl.saves == 2 && mesh.sent.size() == before_reject && own.out_path[0] == 0xC1); + mesh.acl.clients[1].permissions_are_explicit = true; + mesh.command("set outpath B1,B2", 100, 1, 777); + assert(mesh.acl.saves == 3 && mesh.acl.clients[1].out_path_len == 2); + // A prior Admin result must not become readable after the role is demoted. + MyMesh demoted; demoted.acl.clients[0].permissions = PERM_ACL_ADMIN; + demoted.command("get prv.key", 100, 0, 999); + assert(demoted.general_commands == 1 && demoted.reply() == "OK admin"); + demoted.acl.clients[0].permissions = PERM_ACL_READ_WRITE; + demoted.command("get prv.key", 101, 0, 999); + assert(demoted.general_commands == 1 && demoted.reply() != "OK admin"); + for (uint8_t role : {uint8_t(PERM_ACL_FILTER_MGR), uint8_t(PERM_ACL_REGION_MGR), uint8_t(7)}) { + MyMesh revoked; revoked.command("get outpath", 100, 0, 123); + revoked.acl.clients[0].permissions = role; + revoked.command("get outpath", 101, 0, 123); + assert(revoked.reply().find("Err") != std::string::npos); + } + puts("cached setters execute once, sender isolation, replay denial, and demotion gates passed"); +} + +static mesh::Packet* packet(MyMesh& mesh, uint8_t flags = TXT_TYPE_CLI_DATA) { + mesh::Identity id{}; uint8_t secret[PUB_KEY_SIZE]{}; + const uint8_t data[] = {1, 2, 3, 4, uint8_t(flags << 2), 'r', 'e', 'p', 'l', 'y'}; + return mesh.createDatagram(PAYLOAD_TYPE_TXT_MSG, id, secret, data, sizeof(data)); +} +static void alternateCases() { + MyMesh mesh; auto& own = mesh.acl.clients[0]; own.alt_path_len = 1; own.alt_path[0] = 0xB1; + auto p = packet(mesh); p->radio_reply = true; + assert(mesh.sendClientReply(&own, p, 300, 2)); + assert(mesh.sent.size() == 2 && mesh.sent[0].retry_enabled && !mesh.sent[1].retry_enabled); + assert(mesh.sent[0].path[0] == 0xA1 && mesh.sent[1].path[0] == 0xB1); + assert(mesh.sent[0].packet.payload_len == mesh.sent[1].packet.payload_len); + assert(memcmp(mesh.sent[0].packet.payload, mesh.sent[1].packet.payload, p->payload_len) == 0); + assert(mesh.sent[1].packet.radio_reply && mesh._prefs.direct_retry_enabled == 1); + for (unsigned mode = 0; mode < 4; ++mode) { + MyMesh single; auto& c = single.acl.clients[0]; + if (mode == 0) { c.alt_path_len = 1; memcpy(c.alt_path, c.out_path, MAX_PATH_SIZE); c.alt_path[63] ^= 1; } + if (mode == 1) c.alt_path_len = OUT_PATH_FORCE_FLOOD; + if (mode == 2) { c.out_path_len = OUT_PATH_UNKNOWN; c.alt_path_len = 1; } + if (mode == 3) { c.alt_path_len = 1; c.alt_path[0] = 0xB1; single.fail_allocation = 2; } + assert(single.sendClientReply(&c, packet(single), 300, 2)); + assert(single.sent.size() == 1 && single.allocations <= 2); + assert(single.sent[0].direct == (mode != 2)); + } + MyMesh failed; auto& c = failed.acl.clients[0]; c.alt_path_len = 1; c.alt_path[0] = 0xB1; + failed.send_ok = false; + assert(!failed.sendClientReply(&c, packet(failed), 300, 2)); + assert(failed.sent.size() == 1 && failed.releases == 1 && failed._prefs.direct_retry_enabled == 1); + MyMesh exhausted; exhausted.fail_allocation = 1; exhausted.command("set outpath B1"); + assert(exhausted.acl.clients[0].out_path[0] == 0xB1 && exhausted.sent.empty()); + MyMesh duplicate; duplicate.acl.clients[0].alt_path_len = 1; duplicate.acl.clients[0].alt_path[0] = 0xB1; + duplicate.command("set outpath C1", 100, 0, 123); + duplicate.command("set outpath C1", 101, 0, 123); + assert(duplicate.sent.size() == 4 && duplicate.acl.saves == 0); + assert(duplicate.sent[0].packet.payload_len == duplicate.sent[1].packet.payload_len); + assert(memcmp(duplicate.sent[0].packet.payload, duplicate.sent[1].packet.payload, + duplicate.sent[0].packet.payload_len) == 0); + puts("alternate encrypted copies retain primary retries and tolerate admission or pool failure passed"); +} + +static void pushCases() { + MyMesh mesh; auto& c = mesh.acl.clients[0]; c.alt_path_len = 1; c.alt_path[0] = 0xB1; + mesh::Identity author{}; memset(author.pub_key, 0x55, PUB_KEY_SIZE); + assert(mesh.pushRoomTextToClient(&c, 700, author, "hello room", 22)); + assert(mesh.sent.size() == 2 && mesh.retry_replacements == 1 && mesh._num_post_pushes == 1); + assert(mesh.sent[0].retry_enabled && !mesh.sent[1].retry_enabled && mesh.retry_timestamp == 700); + assert(c.extra.room.push_post_timestamp == 700 && c.extra.room.pending_topic_revision == 22); + assert(c.extra.room.pending_ack != 0 && c.extra.room.ack_timeout == ticks + PUSH_TIMEOUT_BASE + 2 * PUSH_ACK_TIMEOUT_FACTOR); + assert(mesh.sent[0].packet.radio_reply && mesh.sent[1].packet.radio_reply); + assert(memcmp(mesh.sent[0].packet.payload, mesh.sent[1].packet.payload, mesh.sent[0].packet.payload_len) == 0); + MyMesh failed; failed.send_ok = false; + assert(!failed.pushRoomTextToClient(&failed.acl.clients[0], 700, author, "hello room", 22)); + assert(failed.retry_replacements == 0 && failed._num_post_pushes == 0); + assert(failed.acl.clients[0].extra.room.pending_ack == 0); + puts("actual room post and topic pushes share copies, ACK state, and primary retry ownership passed"); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "roles")) roleCases(); + else if (!strcmp(argv[1], "allowlist")) exactAllowlist(); + else if (!strcmp(argv[1], "formats")) pathFormats(); + else if (!strcmp(argv[1], "persistence")) persistenceCases(); + else if (!strcmp(argv[1], "retries")) retryCases(); + else if (!strcmp(argv[1], "alternate")) alternateCases(); + else if (!strcmp(argv[1], "push")) pushCases(); + else assert(false); +} +''' + + +def production_source(): + room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text() + acl = (ROOT / "src/helpers/ClientACL.h").read_text() + packet = (ROOT / "src/Packet.cpp").read_text() + constants = "\n".join(line for line in acl.splitlines() + if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) + sources = [room, header, (ROOT / "src/Mesh.h").read_text(), + (ROOT / "src/helpers/TxtDataHelpers.h").read_text()] + for name in ("ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN", "TXT_TYPE_CLI_DATA", "TXT_TYPE_CLI_COMMAND", + "TXT_TYPE_SIGNED_PLAIN", "TXT_ACK_DELAY", "REPLY_DELAY_MILLIS", "REQ_TYPE_KEEP_ALIVE", + "SERVER_RESPONSE_DELAY", "LAZY_CONTACTS_WRITE_DELAY", "PUSH_TIMEOUT_BASE", + "PUSH_ACK_TIMEOUT_FACTOR", "PUSH_ACK_TIMEOUT_FLOOD"): + found = None + for text in sources: + found = re.search(r"^\s*#define\s+" + name + r"\s+.*$", text, re.MULTILINE) + if found: + break + if not found: + raise AssertionError("missing production constant " + name) + constants += "\n" + found[0] + methods = "\n".join(extract_braced(room, signature) for signature in ( + "bool MyMesh::handleClientPathCommand(", "bool MyMesh::sendClientReply(", + "void MyMesh::onPeerDataRecv(", "bool MyMesh::pushRoomTextToClient(", + "void MyMesh::serviceRoomQuotas(", "bool MyMesh::saveFilter(")) + replacements = { + "@CONSTANTS@": constants, + "@STR_HELPER@": extract_braced((ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(), + "void StrHelper::strncpy("), + "@CLIENT_INFO@": extract_braced(acl, "struct ClientInfo {"), + "@PACKET_METHODS@": "\n".join(extract_braced(packet, signature) for signature in ( + "Packet::Packet()", "bool Packet::isValidPathLen(", "uint8_t Packet::copyPath(", + "size_t Packet::writePath(")), + "@PRODUCTION_METHODS@": methods, + "@TEST_CASES@": CASES, + } + generated = FIXTURE.read_text() + for marker, text in replacements.items(): + generated = generated.replace(marker, text) + return generated + + +class RoomClientPathTests(unittest.TestCase): + @classmethod + def build(cls, generated, name): + work = Path(cls.directory.name) + source = work / (name + ".cpp") + source.write_text(generated, encoding="ascii") + binary = work / name + command = [cls.compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-I" + str(ROOT / "test/fixtures/room_history_store"), "-I" + str(ROOT / "src"), + str(source), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + checked = subprocess.run(command, capture_output=True, text=True, timeout=60) + if checked.returncode: + raise AssertionError(checked.stdout + checked.stderr) + return binary + + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise AssertionError("a host C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="room-client-paths-") + cls.addClassCleanup(cls.directory.cleanup) + cls.generated = production_source() + cls.binary = cls.build(cls.generated, "room-paths") + + def run_case(self, name, expected): + checked = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=20) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(expected, checked.stdout) + + def test_all_roles_only_change_the_authenticated_clients_routes(self): + self.run_case("roles", "256 permission combinations and live reader roles remain self-only passed") + + def test_narrow_allowlist_prefixes_posts_bans_and_command_bypass_attempts(self): + self.run_case("allowlist", "exact allowlist, correlation normalization, chat isolation, and ban gates passed") + + def test_path_widths_observed_routes_and_bounded_formatting(self): + self.run_case("formats", "one-to-three byte paths, route sentinels, observed paths, and bounded formatting passed") + + def test_transient_sessions_filtered_storage_noop_and_rollback(self): + self.run_case("persistence", "transient roles stay transient, filtered durability, no-op writes, and save rollback passed") + + def test_replayed_mutations_cached_results_identity_and_revoked_permissions(self): + self.run_case("retries", "cached setters execute once, sender isolation, replay denial, and demotion gates passed") + + def test_alternate_packet_clones_primary_retries_and_allocation_failures(self): + self.run_case("alternate", "alternate encrypted copies retain primary retries and tolerate admission or pool failure passed") + + def test_actual_post_and_topic_pushes_share_ack_and_retry_state(self): + self.run_case("push", "actual room post and topic pushes share copies, ACK state, and primary retry ownership passed") + + def test_negative_controls_detect_rollback_cache_authorization_and_retry_regressions(self): + controls = ( + ("rollback", "memcpy(selected, previous, sizeof(previous));", + "(void)previous;", "persistence"), + ("cache-permission", "if (cached_retry && !client->isAdmin() && !own_path_allowed) {", + "if (false && cached_retry && !client->isAdmin() && !own_path_allowed) {", "retries"), + ("alternate-retries", "_prefs.direct_retry_enabled = 0;", + "_prefs.direct_retry_enabled = 1;", "alternate"), + ) + for name, before, after, case in controls: + with self.subTest(control=name): + self.assertEqual(self.generated.count(before), 1, + "update the negative control to the production seam") + binary = self.build(self.generated.replace(before, after), "negative-" + name) + checked = subprocess.run([str(binary), case], capture_output=True, text=True, timeout=20) + self.assertNotEqual(checked.returncode, 0, + "negative control did not detect " + name) + self.assertIn("Assertion", checked.stderr, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_room_login_integration.py b/test/test_room_login_integration.py index 2afb5cadc..be50bd09f 100644 --- a/test/test_room_login_integration.py +++ b/test/test_room_login_integration.py @@ -25,12 +25,16 @@ HARNESS = r''' #include #include #include +#include +#include +#include #include #include #include #include #include @CONSTANTS@ +@STR_HELPER@ #define MESH_CLIENT_REPEATER_ONLY 0 namespace mesh { struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; }; @@ -86,6 +90,10 @@ struct FakeACL : ClientACL { bool known = false, allocate_ok = true; unsigned lookups = 0, authorizations = 0, allocations = 0; uint8_t authorized_role = 255; + bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) { + assert(fs != nullptr && filter != nullptr); + return true; + } int getNumClients() const { return known ? 1 : 0; } ClientInfo* getClientByIdx(int i) { assert(i == 0 && known); return &client; } ClientInfo* getClient(const uint8_t* key, size_t length) { @@ -107,6 +115,7 @@ struct FakeACL : ClientACL { client.id = sender; client.permissions = permissions; client.out_path_len = OUT_PATH_UNKNOWN; + client.alt_path_len = OUT_PATH_UNKNOWN; client.out_path_is_persistable = true; known = true; return &client; @@ -130,6 +139,7 @@ static uint32_t millis() { return ticks; } struct MyMesh { FakeACL acl; MemoryFS policy_fs; + MemoryFS* _fs = &policy_fs; mesh::RoomAccessPolicy room_access; MyMesh() { metadata_filesystem = &policy_fs; assert(room_access.load(&policy_fs)); } int matching_peer_indexes[1] = {0}; @@ -137,6 +147,8 @@ struct MyMesh { mesh::LogicalMessageCache recent_room_polls; void serviceRoomQuotas(); static bool saveFilter(ClientInfo*); + bool handleClientPathCommand(ClientInfo*, char*, char*); + bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t); unsigned path_acks = 0; bool processAck(const uint8_t*) { ++path_acks; return true; } uint8_t getUnsyncedCount(ClientInfo*) { return 2; } @@ -151,13 +163,14 @@ struct MyMesh { char password[32] = "admin"; char guest_password[32] = "guest"; bool allow_read_only = false; + uint8_t direct_retry_enabled = 1; } _prefs; FakeClock clock; FakeRng rng; unsigned long millis_now = 20000, dirty_contacts_expiry = 0, next_push = 0; uint8_t contacts_save_failures = 0, reply_data[32] = {}; unsigned creations = 0, direct_sends = 0, flood_sends = 0, path_returns = 0; - bool create_ok = true; + bool create_ok = true, queue_ok = true; mesh::Packet response; uint8_t response_secret[PUB_KEY_SIZE] = {}, response_key[PUB_KEY_SIZE] = {}; uint8_t returned_path[MAX_PATH_SIZE] = {}, direct_path[MAX_PATH_SIZE] = {}; @@ -169,6 +182,8 @@ struct MyMesh { FakeClock* getRTCClock() { return &clock; } FakeRng* getRNG() { return &rng; } + mesh::Packet* obtainNewPacket() { return nullptr; } + void releasePacket(mesh::Packet*) { assert(false); } unsigned long futureMillis(unsigned long delay) { return millis_now + delay; } mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& sender, const uint8_t* secret, const uint8_t* data, size_t len) { @@ -188,18 +203,20 @@ struct MyMesh { memcpy(returned_path, path, (path_len & 63) * ((path_len >> 6) + 1)); return createDatagram(type, sender, secret, data, len); } - void sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len, + bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t path_len, unsigned long delay) { assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_DELAY)); assert(mesh::Packet::isValidPathLen(path_len)); ++direct_sends; direct_path_len = path_len; memcpy(direct_path, path, (path_len & 63) * ((path_len >> 6) + 1)); + return queue_ok; } - void sendFloodReply(mesh::Packet* packet, unsigned long delay, uint8_t hash_size) { + bool sendFloodReply(mesh::Packet* packet, unsigned long delay, uint8_t hash_size) { assert(packet == &response && (delay == SERVER_RESPONSE_DELAY || delay == TXT_ACK_DELAY)); ++flood_sends; reply_hash_size = hash_size; + return queue_ok; } bool store_ok = true; bool addPost(ClientInfo*, const char*) { if (!store_ok) return false; ++posts; return true; } @@ -232,6 +249,8 @@ struct MyMesh { @PATH_HANDLER@ @QUOTA_SERVICE@ @SAVE_FILTER@ +@CLIENT_PATH_HANDLER@ +@SEND_CLIENT_REPLY@ static mesh::Identity identity(uint8_t n = 1) { mesh::Identity sender{}; @@ -247,6 +266,7 @@ static void seedKnown(MyMesh& mesh, uint8_t permissions = PERM_ACL_ADMIN) { client.last_timestamp = 99; client.last_activity = 777; client.out_path_len = 2; + client.alt_path_len = OUT_PATH_UNKNOWN; client.out_path[0] = 0xA1; client.out_path[1] = 0xB2; client.out_path_is_persistable = false; @@ -486,6 +506,9 @@ static void sessionRefresh() { mesh.acl.client.out_path_len = route; login(mesh, "guest", 100, 81, flood); const auto& client = mesh.acl.client; + assert(client.observed_path_len == OUT_PATH_UNKNOWN); + assert(client.observed_path_pending == flood); + assert(client.observed_path_expiry == (flood ? mesh.millis_now + 60000 : 0)); assert(client.permissions == (PERM_ACL_REGION_MGR | 0x80)); assert(client.last_timestamp == 100 && client.last_activity == mesh.clock.now); assert(client.extra.room.sync_since == 81 && client.extra.room.last_post_timestamp == 800); @@ -537,6 +560,64 @@ static void sessionRefresh() { puts("real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed"); } +static void observedLoginPath() { + MyMesh mesh; + seedKnown(mesh); + mesh.acl.client.out_path_len = OUT_PATH_FORCE_FLOOD; + login(mesh, "guest", 100, 80, true); + auto& client = mesh.acl.client; + assert(client.observed_path_pending && client.out_path_len == OUT_PATH_FORCE_FLOOD); + uint8_t route[] = {0x12, 0x34, 0x56, 0x78}, ack[4]{}; + auto packet = incoming(); + // ACK and flood packets are not the reciprocal empty-custom login PATH. + mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, + PAYLOAD_TYPE_ACK, ack, sizeof(ack)); + assert(client.observed_path_pending && mesh.path_acks == 1); + auto flood_packet = incoming(true); + mesh.onPeerPathRecv(&flood_packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0); + assert(client.observed_path_pending); + assert(!mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0)); + assert(!client.observed_path_pending && client.observed_path_len == 0x42); + assert(memcmp(client.observed_path, route, sizeof(route)) == 0); + assert(client.out_path_len == OUT_PATH_FORCE_FLOOD); // An observed PATH is not an override. + char get_command[] = "get outpath path", reply[180]{}; + assert(mesh.handleClientPathCommand(&client, get_command, reply)); + assert(strcmp(reply, "> 1234,5678") == 0); + char set_command[] = "set outpath path"; + assert(mesh.handleClientPathCommand(&client, set_command, reply)); + assert(client.out_path_len == 0x42 && client.out_path_is_persistable); + assert(memcmp(client.out_path, route, sizeof(route)) == 0); + login(mesh, "guest", 101, 80, false); + assert(!client.observed_path_pending && client.observed_path_len == OUT_PATH_UNKNOWN); + login(mesh, "guest", 102, 80, true); + mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0, 0x0F, ack, 0); + assert(client.observed_path_len == 0 && !client.observed_path_pending); + char zero_hop[] = "set outpath path"; + assert(mesh.handleClientPathCommand(&client, zero_hop, reply)); + assert(client.out_path_len == 0 && strcmp(reply, "> direct") == 0); + // Timeout and an unrelated PATH cannot create an observed-route result. + login(mesh, "guest", 103, 80, true); + mesh.millis_now += 60000; + mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0); + assert(!client.observed_path_pending && client.observed_path_len == OUT_PATH_UNKNOWN); + login(mesh, "guest", 104, 80, true); + char explicitly_selected[] = "set outpath B1"; + assert(mesh.handleClientPathCommand(&client, explicitly_selected, reply)); + mesh.onPeerPathRecv(&packet, 0, client.shared_secret, route, 0x42, 0x0F, ack, 0); + assert(client.observed_path_len == 0x42 && client.out_path_len == 1 && client.out_path[0] == 0xB1); + // No reciprocal PATH is requested unless the login response was queued. + for (bool allocation_fault : {false, true}) { + MyMesh failed; + failed.create_ok = !allocation_fault; + failed.queue_ok = allocation_fault; + login(failed, "guest", 100, 80, true); + assert(failed.acl.known && failed.acl.client.last_timestamp == 100); + assert(!failed.acl.client.observed_path_pending + && failed.acl.client.observed_path_len == OUT_PATH_UNKNOWN); + } + puts("actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed"); +} + static void readOnlyPosting() { for (const char* password : {"", "wrong"}) { MyMesh mesh; @@ -692,6 +773,7 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "moderation")) moderationCases(); else if (!strcmp(argv[1], "quotas")) quotaCases(); else if (!strcmp(argv[1], "requests")) allRequestQuotaCases(); + else if (!strcmp(argv[1], "observed")) observedLoginPath(); else assert(false); } ''' @@ -737,6 +819,8 @@ class RoomLoginIntegrationTests(unittest.TestCase): constants += "\n" + delay.group() replacements = { "@CONSTANTS@": constants, + "@STR_HELPER@": extract_braced((ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(), + "void StrHelper::strncpy("), "@PACKET_CONSTRUCTOR@": extract_braced(packet, "Packet::Packet()"), "@PACKET_PATH_CHECK@": extract_braced(packet, "bool Packet::isValidPathLen("), "@CLIENT_INFO@": extract_braced(acl_header, "struct ClientInfo {"), @@ -745,6 +829,8 @@ class RoomLoginIntegrationTests(unittest.TestCase): "@PATH_HANDLER@": extract_braced(room, "bool MyMesh::onPeerPathRecv("), "@QUOTA_SERVICE@": extract_braced(room, "void MyMesh::serviceRoomQuotas("), "@SAVE_FILTER@": extract_braced(room, "bool MyMesh::saveFilter("), + "@CLIENT_PATH_HANDLER@": extract_braced(room, "bool MyMesh::handleClientPathCommand("), + "@SEND_CLIENT_REPLY@": extract_braced(room, "bool MyMesh::sendClientReply("), "@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("), } generated = HARNESS @@ -783,6 +869,9 @@ class RoomLoginIntegrationTests(unittest.TestCase): def test_successful_relogin_refreshes_session_and_routes_without_resetting_post_floor(self): self.run_case("refresh", "real-handler successful reconnect refreshes cursor, activity, secret, topic state, and route passed") + def test_observed_route_uses_actual_login_and_path_handlers_with_timeout(self): + self.run_case("observed", "actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed") + def test_all_room_request_types_consume_poll_budget_and_preserve_exact_retries(self): self.run_case("requests", "actual request quota covers status, telemetry, ACL, board, and exact payload retries passed") diff --git a/test/test_room_topic_delivery.py b/test/test_room_topic_delivery.py index b9c64e732..e535848bc 100644 --- a/test/test_room_topic_delivery.py +++ b/test/test_room_topic_delivery.py @@ -27,9 +27,13 @@ class RoomTopicDeliveryTests(unittest.TestCase): clock = (ROOT / "src/MeshCore.h").read_text() generated += "namespace mesh {\n" + extract_braced(clock, "class RTCClock") + ";\n}\n" definitions = "\n".join(extract_braced(source, signature) for signature in ( + "bool MyMesh::sendClientReply(", "bool MyMesh::pushPostToClient(", "bool MyMesh::pushRoomTextToClient(", "bool MyMesh::processAck(", "void MyMesh::activateRoomTopic(", "bool MyMesh::handleRoomTopicCommand(", "void MyMesh::serviceRoomPush(")) + packet = (ROOT / "src/Packet.cpp").read_text() + definitions = "namespace mesh {\n" + extract_braced( + packet, "bool Packet::isValidPathLen(") + "\n}\n" + definitions with tempfile.TemporaryDirectory(prefix="room-topic-delivery-") as directory: work = Path(directory) (work / "state.inc").write_text(generated)