From 65a28582dde00f8774e8e04b34282341b7ae377a Mon Sep 17 00:00:00 2001 From: hank Date: Tue, 28 Apr 2026 20:54:11 -0700 Subject: [PATCH] Add PERM_ACL_REGION_MGR role for delegated region management Introduces a fourth ACL role (value 4) that can manage the region map without full admin privileges. The role is intended for trusted users who curate regions on a repeater but should not have access to general admin commands. ClientACL: - Widen PERM_ACL_ROLE_MASK from 2 to 3 bits so the new value fits. - Add PERM_ACL_REGION_MGR and ClientInfo::isRegionMgr(). - Exempt region_mgr entries from least-recently-active eviction in putClient(), same as admins. simple_repeater: - Phones may still gate UI on the legacy is_admin byte (reply_data[6]), so report region_mgr as admin there. Without this, the phone CLI falls back to guest view. - Allow region_mgr to send TXT_MSG CLI commands. handleCommand() gates non-whitelisted commands with "Err - not permitted". The whitelist covers region.* (read+write) plus a small set of read-only queries (get, ver, board, neighbors, clock, sensor get/list). - Pass the ClientInfo* through to handleCommand and drop the redundant sender_timestamp parameter (derived from sender->last_timestamp; NULL means Serial CLI). - Use ~PERM_ACL_ROLE_MASK instead of ~0x03 when clearing role bits on login, so the wider mask is honored. --- examples/simple_repeater/MyMesh.cpp | 39 +++++++++++++++++++++++++---- examples/simple_repeater/MyMesh.h | 2 +- examples/simple_repeater/main.cpp | 2 +- src/helpers/ClientACL.cpp | 3 ++- src/helpers/ClientACL.h | 4 ++- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 666f79fc5..24bcfac5e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -119,7 +119,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr MESH_DEBUG_PRINTLN("Login success!"); client->last_timestamp = sender_timestamp; client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions &= ~0x03; + client->permissions &= ~PERM_ACL_ROLE_MASK; client->permissions |= perms; memcpy(client->shared_secret, secret, PUB_KEY_SIZE); @@ -136,7 +136,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp reply_data[4] = RESP_SERVER_LOGIN_OK; reply_data[5] = 0; // Legacy: was recommended keep-alive interval (secs / 16) - reply_data[6] = client->isAdmin() ? 1 : 0; + reply_data[6] = (client->isAdmin() || client->isRegionMgr()) ? 1 : 0; reply_data[7] = client->permissions; getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness reply_data[12] = FIRMWARE_VER_LEVEL; // New field @@ -682,7 +682,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } else { MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); } - } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && client->isAdmin()) { // a CLI command + } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && (client->isAdmin() || client->isRegionMgr())) { // a CLI command 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 @@ -719,7 +719,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (is_retry) { *reply = 0; } else { - handleCommand(sender_timestamp, command, reply); + handleCommand(client, command, reply); } int text_len = strlen(reply); if (text_len > 0) { @@ -1165,7 +1165,27 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } -void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { +// Whitelist helper for region manager command perms +static bool isRegionMgrAllowed(const char* cmd) { + while(*cmd == ' ') cmd++; // skip leading spaces + // region commands (read + write region map) + if (memcmp(cmd, "region", 6) == 0) return true; + // read-only getters / status + if (memcmp(cmd, "get ", 4) == 0) return true; + if (memcmp(cmd, "ver", 3) == 0) return true; + if (memcmp(cmd, "board", 5) == 0) return true; + // "neighbors" (plural) is read-only; reject "neighbor.remove" by checking next char + if (memcmp(cmd, "neighbors", 9) == 0) return true; + // bare "clock" is read-only; "clock sync" must be denied + if (memcmp(cmd, "clock", 5) == 0 && memcmp(cmd, "clock sync", 10) != 0) return true; + // sensor reads only + if (memcmp(cmd, "sensor get ", 11) == 0) return true; + if (memcmp(cmd, "sensor list", 11) == 0) return true; + return false; +} + +void MyMesh::handleCommand(ClientInfo* sender, char *command, char *reply) { + uint32_t sender_timestamp = sender ? sender->last_timestamp : 0; // Serial CLI passes NULL if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map @@ -1208,6 +1228,15 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply command += 3; } + // Region managers are limited to read-only queries and region commands + // Admins are unrestricted + if (sender && !sender->isAdmin() && sender->isRegionMgr()) { + if (!isRegionMgrAllowed(command)) { + strcpy(reply, "Err - not permitted"); + return; + } + } + // handle ACL related commands if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8} char* hex = &command[8]; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ed0317e6..c14a25d7a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -223,7 +223,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void handleCommand(ClientInfo* sender, char* command, char* reply); void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e37078ce5..5d6bb2605 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -124,7 +124,7 @@ void loop() { Serial.print('\n'); command[len - 1] = 0; // replace newline with C string null terminator char reply[160]; - the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! + the_mesh.handleCommand(NULL, command, reply); // NOTE: sender is NULL via serial if (reply[0]) { Serial.print(" -> "); Serial.println(reply); } diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 128238273..8abdd565e 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -99,7 +99,8 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { ClientInfo* oldest = &clients[MAX_CLIENTS - 1]; for (int i = 0; i < num_clients; i++) { if (id.matches(clients[i].id)) return &clients[i]; // already known - if (!clients[i].isAdmin() && clients[i].last_activity < min_time) { + if ( (!clients[i].isAdmin() && !clients[i].isRegionMgr()) + && clients[i].last_activity < min_time) { oldest = &clients[i]; min_time = oldest->last_activity; } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index b758f7068..1961546d1 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -4,11 +4,12 @@ #include #include -#define PERM_ACL_ROLE_MASK 3 // lower 2 bits +#define PERM_ACL_ROLE_MASK 7 // lower 3 bits #define PERM_ACL_GUEST 0 #define PERM_ACL_READ_ONLY 1 #define PERM_ACL_READ_WRITE 2 #define PERM_ACL_ADMIN 3 +#define PERM_ACL_REGION_MGR 4 #define OUT_PATH_UNKNOWN 0xFF @@ -31,6 +32,7 @@ struct ClientInfo { } extra; bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } + bool isRegionMgr() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_REGION_MGR; } }; #ifndef MAX_CLIENTS