From ef92505429770e9946dce4c4efb1c32f0a0ba80e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 2 Sep 2026 16:35:03 -0700 Subject: [PATCH] Add observed login path commands --- docs/cli_commands.md | 12 +- docs/halo_keymind_settings.md | 16 ++- examples/simple_repeater/MyMesh.cpp | 92 +++++++++++---- examples/simple_repeater/MyMesh.h | 2 +- src/helpers/ClientACL.cpp | 2 + src/helpers/ClientACL.h | 4 + src/helpers/ClientPathObservation.h | 93 ++++++++++++++++ .../test_client_path_observation.cpp | 105 ++++++++++++++++++ 8 files changed, 302 insertions(+), 24 deletions(-) create mode 100644 src/helpers/ClientPathObservation.h create mode 100644 test/test_client_path_observation/test_client_path_observation.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index caccfa1c..d46f155a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1550,7 +1550,9 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu #### View or set the direct path override for the current remote client **Usage:** - `get outpath` +- `get outpath path` - `set outpath ` +- `set outpath path` - `set outpath direct` - `set outpath clear` - `set outpath flood` @@ -1560,8 +1562,16 @@ 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. +- `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 + `> path pending`; retry shortly. The observation window expires after one + minute, and the captured login path is not automatically selected. +- `set outpath path` copies that observed route to `outpath` and saves it. It + returns an error without changing `outpath` if no route was received. - `direct` selects a zero-hop route for a directly reachable caller. -- `clear` forgets the override so normal path discovery can repopulate it. +- `clear` forgets the override, replies `> outpath cleared`, and allows normal + path discovery to repopulate it. - `flood` forces replies to use flood packets until the client logs in again. --- diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 76850e7d..c9d505de 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -195,7 +195,9 @@ can be copied directly into another `set outpath` or `set altpath` command. ```text get outpath +get outpath path set outpath A1B2C3,D4E5F6 +set outpath path set outpath direct set outpath clear set outpath flood @@ -214,10 +216,18 @@ For example, both `set altpath 600000,0d2784,f8dada` and The first CLI word is case-insensitive (`set`, `Set`, and `SET` are the same, as are `get`, `Get`, and the other command verbs). Argument case is preserved. +`get outpath path` reports the reciprocal path received after the latest flood +login without changing `outpath`. That packet arrives asynchronously, so retry +shortly if the first query reports `> path pending`; the observation window +expires after one minute. The login path is not automatically selected. +`set outpath path` copies the observed route into `outpath` and saves it; it +leaves the current route unchanged when no observed route is available. + `set outpath direct` sets a zero-hop direct route for a client reachable without -repeaters. `set outpath clear` forgets the override and lets normal path -discovery fill it again. `set outpath flood` forces replies to use flood packets -until the client logs in again. +repeaters. `set outpath clear` forgets the override, replies +`> outpath cleared`, and lets normal path discovery fill it again. +`set outpath flood` forces replies to use flood packets until the client logs in +again. When `outpath` is a valid direct path and `altpath` is also a valid, different direct path, repeater DM replies send two packets: one on `outpath` and one on diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8d9f6eb6..e4d70ccf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,8 @@ extern "C" caddr_t _sbrk(int increment); #define FIRMWARE_VER_LEVEL 2 +static constexpr uint32_t LOGIN_PATH_OBSERVATION_TIMEOUT_MS = 60000UL; + #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS #define REQ_TYPE_KEEP_ALIVE 0x02 #define REQ_TYPE_GET_TELEMETRY_DATA 0x03 @@ -582,6 +585,9 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr *client, client_existed, perms, PERM_ACL_ROLE_MASK, secret, sender_timestamp, getRTCClock()->getCurrentTime(), reset_out_path, OUT_PATH_UNKNOWN); + // A flood login arms observation only after its PATH reply is successfully + // allocated below. Clear any result from an earlier login in either case. + mesh::clearObservedClientPath(*client, OUT_PATH_UNKNOWN); const bool persistence_needed = mesh::successfulClientLoginNeedsPersistence( @@ -914,7 +920,7 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) return n >= max_counters[hash_size]; } -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 @@ -922,17 +928,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; } static bool directPathsEqual(const uint8_t* a_path, uint8_t a_len, const uint8_t* b_path, uint8_t b_len) { @@ -2576,7 +2580,15 @@ 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, secret, packet->path, packet->path_len, PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + if (path) { + const bool login_path_sent = sendFloodReply( + path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + if (client != NULL && login_path_sent) { + mesh::beginObservedClientPath( + *client, OUT_PATH_UNKNOWN, + futureMillis(LOGIN_PATH_OBSERVATION_TIMEOUT_MS)); + } + } return; } @@ -3161,16 +3173,26 @@ 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); + // A flood login's reciprocal PATH is direct and has no embedded payload + // (decoded as the reserved 0x0F extra type). Retain that one independently + // so the operator can inspect or explicitly select it. + const bool captured_login_path = packet->isRouteDirect() + && extra_type == 0x0F + && mesh::captureObservedClientPath( + *client, path, path_len, futureMillis(0)); + // PATH packets authenticate the sender and are de-duplicated in RAM, but // the protocol carries no timestamp or request nonce. Keep a learned route // useful for this boot without making an arbitrarily old replay durable. - const bool persistence_allowed = mesh::clientPathPersistenceAllowed( - client->permissions != 0, 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; + if (!captured_login_path) { + const bool persistence_allowed = mesh::clientPathPersistenceAllowed( + client->permissions != 0, 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); @@ -10609,7 +10631,11 @@ static __attribute__((always_inline)) ClientPathCommand classifyClientPathComman } if (is_get) { - if (path[7] != 0) return CLIENT_PATH_NONE; + if (path[7] != 0 + && ((result & CLIENT_PATH_ALT) != 0 + || strcmp(path + 7, " path") != 0)) { + return CLIENT_PATH_NONE; + } } else if (path[7] != 0 && path[7] != ' ') { return CLIENT_PATH_NONE; } @@ -10622,6 +10648,9 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re bool is_get = (path_command & CLIENT_PATH_SET) == 0; bool is_alt = (path_command & CLIENT_PATH_ALT) != 0; + bool use_observed_path = !is_alt + && (strcmp(command, "get outpath path") == 0 + || strcmp(command, "set outpath path") == 0); if (sender == NULL) { strcpy(reply, "Err - command needs remote client context"); return true; @@ -10630,7 +10659,14 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re uint8_t* stored_path = is_alt ? sender->alt_path : sender->out_path; uint8_t* stored_path_len = is_alt ? &sender->alt_path_len : &sender->out_path_len; if (is_get) { - formatPathReply(stored_path, *stored_path_len, reply, 160); + if (use_observed_path + && mesh::isObservedClientPathPending(*sender, futureMillis(0))) { + strcpy(reply, "> path pending"); + } else if (use_observed_path) { + formatPathReply(sender->observed_path, sender->observed_path_len, reply, 160); + } else { + formatPathReply(stored_path, *stored_path_len, reply, 160); + } return true; } @@ -10640,7 +10676,21 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re uint8_t path[MAX_PATH_SIZE]; uint8_t path_len = OUT_PATH_UNKNOWN; const char* err = NULL; - if (!parsePathCommand(spec, path, path_len, err)) { + if (use_observed_path + && mesh::isObservedClientPathPending(*sender, futureMillis(0))) { + strcpy(reply, "Err - path pending"); + return true; + } + if (use_observed_path) { + if (!mesh::isValidObservedClientPathLength( + sender->observed_path_len, sizeof(sender->observed_path))) { + strcpy(reply, "Err - no path received"); + return true; + } + path_len = sender->observed_path_len; + const size_t path_bytes = mesh::observedClientPathByteLength(path_len); + if (path_bytes != 0) memcpy(path, sender->observed_path, path_bytes); + } else if (!parsePathCommand(spec, path, path_len, err)) { strcpy(reply, err ? err : "Err - invalid path"); return true; } @@ -10672,7 +10722,11 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re } mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, contacts_save_failures); - formatPathReply(stored_path, *stored_path_len, reply, 160); + if (!is_alt && path_len == OUT_PATH_UNKNOWN) { + strcpy(reply, "> outpath cleared"); + } else { + formatPathReply(stored_path, *stored_path_len, reply, 160); + } return true; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 40201461..4de3cbec 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -893,7 +893,7 @@ protected: void onControlDataRecv(mesh::Packet* packet) override; // OTA mesh-integration is centralized in mesh::Mesh (no per-example onOtaRecv / send adapter / tick). - 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); void sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); public: diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 3b10da64..98f195f7 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -461,6 +461,7 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { memset(&c, 0, sizeof(c)); c.alt_path_len = OUT_PATH_UNKNOWN; + c.observed_path_len = OUT_PATH_UNKNOWN; bool success = (file.read(pub_key, 32) == 32); success = success && (file.read((uint8_t *) &c.permissions, 1) == 1); @@ -694,6 +695,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { c->out_path_len = OUT_PATH_UNKNOWN; c->out_path_is_persistable = true; c->alt_path_len = OUT_PATH_UNKNOWN; + c->observed_path_len = OUT_PATH_UNKNOWN; return c; } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index bf609b21..9f18e580 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -23,6 +23,10 @@ struct ClientInfo { bool out_path_is_persistable; // live route may replace, but not erase, durable route uint8_t alt_path_len; uint8_t alt_path[MAX_PATH_SIZE]; + uint8_t observed_path_len; // reciprocal login PATH (transient) + uint8_t observed_path[MAX_PATH_SIZE]; + bool observed_path_pending; + uint32_t observed_path_expiry; uint8_t shared_secret[PUB_KEY_SIZE]; uint32_t last_timestamp; // by THEIR clock (exact live floor; reserved ceiling after load) uint32_t last_activity; // by OUR clock (transient) diff --git a/src/helpers/ClientPathObservation.h b/src/helpers/ClientPathObservation.h new file mode 100644 index 00000000..ff772101 --- /dev/null +++ b/src/helpers/ClientPathObservation.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +inline size_t observedClientPathByteLength(uint8_t encoded_path_len) { + const size_t hash_count = encoded_path_len & 63U; + const size_t hash_size = (encoded_path_len >> 6) + 1U; + return hash_count * hash_size; +} + +inline bool isValidObservedClientPathLength(uint8_t encoded_path_len, + size_t capacity) { + const size_t hash_size = (encoded_path_len >> 6) + 1U; + return hash_size != 4U + && observedClientPathByteLength(encoded_path_len) <= capacity; +} + +template +void clearObservedClientPath(Client& client, uint8_t unknown_path) { + memset(client.observed_path, 0, sizeof(client.observed_path)); + client.observed_path_len = unknown_path; + client.observed_path_pending = false; + client.observed_path_expiry = 0; +} + +template +void beginObservedClientPath(Client& client, uint8_t unknown_path, + uint32_t expiry) { + clearObservedClientPath(client, unknown_path); + client.observed_path_pending = true; + client.observed_path_expiry = expiry; +} + +template +bool isObservedClientPathPending(Client& client, uint32_t now) { + if (!client.observed_path_pending) return false; + if (static_cast(now - client.observed_path_expiry) < 0) return true; + + client.observed_path_pending = false; + client.observed_path_expiry = 0; + return false; +} + +// Keep the most recently received, authenticated PAYLOAD_TYPE_PATH separate +// from the selected output route so CLI reads never need to mutate out_path. +template +bool captureObservedClientPath(Client& client, const uint8_t* path, + uint8_t encoded_path_len, uint32_t now) { + if (!isObservedClientPathPending(client, now)) return false; + + const size_t path_bytes = observedClientPathByteLength(encoded_path_len); + if (!isValidObservedClientPathLength( + encoded_path_len, sizeof(client.observed_path)) + || (path_bytes != 0 && path == NULL)) { + return false; + } + + memset(client.observed_path, 0, sizeof(client.observed_path)); + if (path_bytes != 0) { + memcpy(client.observed_path, path, path_bytes); + } + client.observed_path_len = encoded_path_len; + client.observed_path_pending = false; + client.observed_path_expiry = 0; + return true; +} + +// Promote a previously observed PATH to the selected output route. Invalid +// sentinels (including UNKNOWN/FORCE_FLOOD) cannot be promoted. +template +bool promoteObservedClientPath(Client& client) { + const uint8_t encoded_path_len = client.observed_path_len; + if (!isValidObservedClientPathLength( + encoded_path_len, sizeof(client.observed_path))) { + return false; + } + + const size_t path_bytes = observedClientPathByteLength(encoded_path_len); + if (path_bytes > sizeof(client.out_path)) return false; + + memset(client.out_path, 0, sizeof(client.out_path)); + if (path_bytes != 0) { + memcpy(client.out_path, client.observed_path, path_bytes); + } + client.out_path_len = encoded_path_len; + return true; +} + +} // namespace mesh diff --git a/test/test_client_path_observation/test_client_path_observation.cpp b/test/test_client_path_observation/test_client_path_observation.cpp new file mode 100644 index 00000000..3e801e77 --- /dev/null +++ b/test/test_client_path_observation/test_client_path_observation.cpp @@ -0,0 +1,105 @@ +#include + +#include + +namespace { + +static constexpr uint8_t UNKNOWN_PATH = 0xFF; + +struct ClientState { + uint8_t out_path_len = UNKNOWN_PATH; + uint8_t out_path[64] = {}; + uint8_t observed_path_len = UNKNOWN_PATH; + uint8_t observed_path[64] = {}; + bool observed_path_pending = false; + uint32_t observed_path_expiry = 0; +}; + +TEST(ClientPathObservation, CaptureDoesNotChangeSelectedOutputPath) { + ClientState client; + client.out_path_len = 1; + client.out_path[0] = 0xAA; + const uint8_t received[] = {0x10, 0x20, 0x30, 0x40}; + + mesh::beginObservedClientPath(client, UNKNOWN_PATH, 1000); + ASSERT_TRUE(mesh::captureObservedClientPath( + client, received, 0x42, 500)); // two 2-byte hashes + EXPECT_EQ(client.observed_path_len, 0x42); + EXPECT_EQ(memcmp(client.observed_path, received, sizeof(received)), 0); + EXPECT_EQ(client.out_path_len, 1); + EXPECT_EQ(client.out_path[0], 0xAA); +} + +TEST(ClientPathObservation, PromoteCopiesObservedPathToOutputPath) { + ClientState client; + const uint8_t received[] = {0x10, 0x20, 0x30, 0x40}; + mesh::beginObservedClientPath(client, UNKNOWN_PATH, 1000); + ASSERT_TRUE(mesh::captureObservedClientPath(client, received, 0x42, 500)); + + ASSERT_TRUE(mesh::promoteObservedClientPath(client)); + EXPECT_EQ(client.out_path_len, 0x42); + EXPECT_EQ(memcmp(client.out_path, received, sizeof(received)), 0); +} + +TEST(ClientPathObservation, MissingObservedPathCannotReplaceOutputPath) { + ClientState client; + client.out_path_len = 1; + client.out_path[0] = 0xAA; + + EXPECT_FALSE(mesh::promoteObservedClientPath(client)); + EXPECT_EQ(client.out_path_len, 1); + EXPECT_EQ(client.out_path[0], 0xAA); +} + +TEST(ClientPathObservation, ZeroHopDirectPathCanBeObservedAndPromoted) { + ClientState client; + mesh::beginObservedClientPath(client, UNKNOWN_PATH, 1000); + ASSERT_TRUE(mesh::captureObservedClientPath(client, nullptr, 0, 500)); + ASSERT_TRUE(mesh::promoteObservedClientPath(client)); + EXPECT_EQ(client.out_path_len, 0); +} + +TEST(ClientPathObservation, NewLoginClearsPreviousObservation) { + ClientState client; + client.observed_path_len = 1; + client.observed_path[0] = 0x55; + + mesh::clearObservedClientPath(client, UNKNOWN_PATH); + EXPECT_EQ(client.observed_path_len, UNKNOWN_PATH); + EXPECT_FALSE(client.observed_path_pending); + EXPECT_EQ(client.observed_path_expiry, 0U); + for (uint8_t byte : client.observed_path) EXPECT_EQ(byte, 0); +} + +TEST(ClientPathObservation, NewFloodLoginWaitsForOneReciprocalPath) { + ClientState client; + const uint8_t first[] = {0x11}; + const uint8_t later[] = {0x22}; + + mesh::beginObservedClientPath(client, UNKNOWN_PATH, 1000); + EXPECT_TRUE(client.observed_path_pending); + ASSERT_TRUE(mesh::captureObservedClientPath(client, first, 1, 500)); + EXPECT_FALSE(client.observed_path_pending); + EXPECT_EQ(client.observed_path[0], 0x11); + + EXPECT_FALSE(mesh::captureObservedClientPath(client, later, 1, 600)); + EXPECT_EQ(client.observed_path[0], 0x11); +} + +TEST(ClientPathObservation, PendingLoginPathExpires) { + ClientState client; + const uint8_t received[] = {0x11}; + + mesh::beginObservedClientPath(client, UNKNOWN_PATH, 1000); + EXPECT_TRUE(mesh::isObservedClientPathPending(client, 999)); + EXPECT_FALSE(mesh::captureObservedClientPath(client, received, 1, 1000)); + EXPECT_FALSE(client.observed_path_pending); + EXPECT_EQ(client.observed_path_len, UNKNOWN_PATH); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}