Let room users manage their own return routes

This commit is contained in:
mikecarper committed 2026-10-10 11:27:39 -07:00
1 parent 5cb8367d12
commit 0eb1d03a16
15 files changed
+1098 -54

No files matched your search

+1
View File
@@ -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
+4 -4
View File
@@ -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 <pubkey> <permissions>`](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 <pubkey> <permissions>`](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 |
+3
View File
@@ -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
+4
View File
@@ -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
+59
View File
@@ -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.
+186 -37
View File
@@ -25,6 +25,8 @@
#include <helpers/RoomTopicStore.h>
#include <helpers/RoomBoardProtocol.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/ClientPathObservation.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/LazyPersistence.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
@@ -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) {
+4 -1
View File
@@ -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;
+67
View File
@@ -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
@@ -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");
+196
View File
@@ -0,0 +1,196 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "filesystem.h"
#include <Packet.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/ClientPathObservation.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
#include <helpers/ClientACLResponse.h>
#include <helpers/LazyPersistence.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/TxtDataHelpers.h>
@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<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> 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> 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<uint8_t> data(5 + strlen(text) + mesh::RemoteCliRequest::EXTENSION_SIZE + 1);
memcpy(data.data(), &timestamp, 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@
+68 -9
View File
@@ -6,6 +6,7 @@
#include <vector>
#include "../room_history_store/filesystem.h"
#include <helpers/RoomAccessPolicy.h>
#include <helpers/ClientPathPersistence.h>
#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> 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<char*>(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<char*>(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<const uint8_t*>(&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");
}
@@ -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()")]
+394
View File
@@ -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()
+92 -3
View File
@@ -25,12 +25,16 @@ HARNESS = r'''
#include <helpers/RemoteCliRequest.h>
#include <helpers/ClientACLResponse.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/ClientPathObservation.h>
#include <helpers/RoomClientPathCommand.h>
#include <helpers/TxtDataHelpers.h>
#include <Packet.h>
#include <helpers/RoomLoginAuthorization.h>
#include <helpers/ClientLoginPersistence.h>
#include <helpers/LazyPersistence.h>
#include <helpers/LogicalMessageCache.h>
@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<ROOM_MESSAGE_CACHE_SIZE> 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")
+4
View File
@@ -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)