Allow authenticated LoRa admins to read paged ACLs
This commit is contained in:
1 parent
00163f2c2b
commit
7dd48ff0e2
10 files changed
+491
-10
No files matched your search
@@ -177,7 +177,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| 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 |
|
||||
| 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`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local |
|
||||
| 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 |
|
||||
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes |
|
||||
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes |
|
||||
@@ -344,7 +344,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| 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 |
|
||||
| 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`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local | Local | Local |
|
||||
| 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 |
|
||||
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
|
||||
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
|
||||
|
||||
+20
-4
@@ -2900,11 +2900,27 @@ eviction like administrators.
|
||||
---
|
||||
|
||||
#### View the current ACL
|
||||
**Usage:**
|
||||
- `get acl`
|
||||
|
||||
**Local connection only:** USB, direct network CLI, or Companion binary
|
||||
command `0x42` where the role supports the command; unavailable over LoRa.
|
||||
**Usage:**
|
||||
|
||||
- `get acl` - full listing locally; first page over LoRa
|
||||
- `get acl <page>` - a numbered page, starting at 1, locally or over LoRa
|
||||
|
||||
Available on repeaters, room servers, and sensors. Over LoRa, an authenticated
|
||||
admin session is required; guests, read-only/read-write clients, and delegated
|
||||
region/filter managers cannot read the ACL.
|
||||
|
||||
Each page starts with `ACL <page>/<pages>` and contains up to two entries.
|
||||
Each entry is a two-digit hexadecimal permission byte followed by the full
|
||||
64-character public key. For example, use `get acl 2` for the second page.
|
||||
An empty list returns `ACL: empty`. Deleted/guest entries with permission byte
|
||||
`00` are omitted, matching the local listing. Pages reflect the current live
|
||||
ACL, so entries can move between pages if permissions or clients change.
|
||||
|
||||
Bare `get acl` keeps the full streamed listing on USB and direct network CLI,
|
||||
or via Companion binary command `0x42` where the role supports the command.
|
||||
The listing contains public keys and permissions, not passwords or shared
|
||||
secrets, and does not change the ACL.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <algorithm>
|
||||
#include <stdlib.h> // for qsort()
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClientACLCLI.h>
|
||||
#include <helpers/ClockSyncUtils.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
@@ -11809,6 +11810,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
&& (strcmp(command, "get recent.repeater") == 0 || strcmp(command, "get recent.repeaters") == 0)) {
|
||||
printRecentRepeatersSerial();
|
||||
reply_start[0] = 0;
|
||||
} else if (mesh::cli::handleACLGet(acl, command, reply, 160 - 3,
|
||||
sender_timestamp == 0)) {
|
||||
// The page fits one LoRa reply, including an optional CLI prefix.
|
||||
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <helpers/FileRead.h>
|
||||
#include <helpers/radiolib/RxBoostedGainDefaults.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClientACLCLI.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
@@ -2356,6 +2357,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
strcpy(reply, "Err - bad pubkey");
|
||||
}
|
||||
}
|
||||
} else if (mesh::cli::handleACLGet(acl, command, reply, 160 - 3,
|
||||
sender_timestamp == 0)) {
|
||||
// The page fits one LoRa reply, including an optional CLI prefix.
|
||||
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
|
||||
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
|
||||
if (_command_output) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "SensorMesh.h"
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClientACLCLI.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
@@ -624,6 +625,9 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
|
||||
strcpy(reply, "Err - bad pubkey");
|
||||
}
|
||||
}
|
||||
} else if (mesh::cli::handleACLGet(acl, command, reply, 160 - 3,
|
||||
sender_timestamp == 0)) {
|
||||
// The page fits one LoRa reply, including an optional CLI prefix.
|
||||
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
|
||||
Stream& console = mesh::usbConsolePort();
|
||||
console.println("ACL:");
|
||||
|
||||
@@ -103,4 +103,5 @@ public:
|
||||
|
||||
int getNumClients() const { return num_clients; }
|
||||
ClientInfo* getClientByIdx(int idx) { return &clients[idx]; }
|
||||
const ClientInfo* getClientByIdx(int idx) const { return &clients[idx]; }
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include "ClientACL.h"
|
||||
#include <Utils.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace cli {
|
||||
|
||||
// Call after the role's admin authorization check. Bare local `get acl`
|
||||
// retains its streamed listing; radio requests and explicit pages use one
|
||||
// bounded reply. Callers reserve three bytes for the companion CLI prefix.
|
||||
inline bool handleACLGet(const ClientACL& acl, const char* command,
|
||||
char* reply, size_t capacity, bool local) {
|
||||
static const char prefix[] = "get acl";
|
||||
const size_t prefix_len = sizeof(prefix) - 1;
|
||||
if (strncmp(command, prefix, prefix_len) != 0) return false;
|
||||
const char* arg = command + prefix_len;
|
||||
if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false;
|
||||
if (local && *arg == 0) return false;
|
||||
if (reply == nullptr || capacity == 0) return true;
|
||||
|
||||
while (*arg == ' ' || *arg == '\t') ++arg;
|
||||
unsigned page = 1;
|
||||
if (*arg >= '0' && *arg <= '9') {
|
||||
page = 0;
|
||||
do {
|
||||
page = page * 10 + unsigned(*arg++ - '0');
|
||||
if (page > 65535) break;
|
||||
} while (*arg >= '0' && *arg <= '9');
|
||||
}
|
||||
while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg;
|
||||
if (*arg || page == 0 || page > 65535) {
|
||||
snprintf(reply, capacity, "Err - usage: get acl [page]");
|
||||
return true;
|
||||
}
|
||||
|
||||
unsigned total = 0;
|
||||
for (int i = 0; i < acl.getNumClients(); ++i) {
|
||||
if (acl.getClientByIdx(i)->permissions != 0) ++total;
|
||||
}
|
||||
if (total == 0 && page == 1) {
|
||||
snprintf(reply, capacity, "ACL: empty");
|
||||
return true;
|
||||
}
|
||||
static constexpr unsigned PAGE_SIZE = 2;
|
||||
const unsigned pages = total == 0 ? 1 : (total + PAGE_SIZE - 1) / PAGE_SIZE;
|
||||
if (page > pages) {
|
||||
snprintf(reply, capacity, "Err - ACL page range: 1-%u", pages);
|
||||
return true;
|
||||
}
|
||||
|
||||
const unsigned offset = (page - 1) * PAGE_SIZE;
|
||||
const unsigned rows = total - offset < PAGE_SIZE ? total - offset : PAGE_SIZE;
|
||||
const int header_len = snprintf(reply, capacity, "ACL %u/%u", page, pages);
|
||||
// Never return a truncated key if a caller supplies a smaller buffer.
|
||||
static constexpr size_t ROW_BYTES = 4 + PUB_KEY_SIZE * 2;
|
||||
if (header_len < 0 || size_t(header_len) >= capacity
|
||||
|| rows > (capacity - size_t(header_len) - 1) / ROW_BYTES) {
|
||||
snprintf(reply, capacity, "Err - ACL reply too small");
|
||||
return true;
|
||||
}
|
||||
size_t used = size_t(header_len);
|
||||
unsigned active_index = 0;
|
||||
unsigned written = 0;
|
||||
for (int i = 0; i < acl.getNumClients() && written < rows; ++i) {
|
||||
const auto* client = acl.getClientByIdx(i);
|
||||
if (client->permissions == 0) continue; // match the local listing
|
||||
if (active_index++ < offset) continue;
|
||||
char key[PUB_KEY_SIZE * 2 + 1];
|
||||
Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
|
||||
used += snprintf(reply + used, capacity - used, "\n%02X %s",
|
||||
unsigned(client->permissions), key);
|
||||
++written;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,241 @@
|
||||
#include <helpers/ClientACL.cpp>
|
||||
#include <helpers/ClientACLCLI.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#define CHECK(condition) do { if (!(condition)) { \
|
||||
std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); std::exit(1); \
|
||||
} } while (0)
|
||||
|
||||
class Stream {
|
||||
public:
|
||||
std::string output;
|
||||
void print(char c) { output += c; }
|
||||
void println(const char* text) { output += text; output += '\n'; }
|
||||
void printf(const char* format, ...) {
|
||||
char row[256];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
const int len = vsnprintf(row, sizeof(row), format, args);
|
||||
va_end(args);
|
||||
CHECK(len >= 0 && size_t(len) < sizeof(row));
|
||||
output += row;
|
||||
}
|
||||
};
|
||||
|
||||
namespace mesh {
|
||||
static Stream console;
|
||||
Stream& usbConsolePort() { return console; }
|
||||
}
|
||||
|
||||
#include "production.h"
|
||||
|
||||
static ClientInfo* add(ClientACL& acl, unsigned index, uint8_t permissions) {
|
||||
uint8_t key[PUB_KEY_SIZE];
|
||||
for (size_t i = 0; i < sizeof(key); ++i) key[i] = uint8_t(i * 7);
|
||||
// Deliberately share the first 12 hex characters between every entry.
|
||||
key[30] = uint8_t(index >> 8);
|
||||
key[31] = uint8_t(index);
|
||||
auto* client = acl.putClient(mesh::Identity(key), permissions);
|
||||
CHECK(client != nullptr);
|
||||
memset(client->shared_secret, 0xFA, sizeof(client->shared_secret));
|
||||
client->last_activity = index + 123;
|
||||
client->last_timestamp = index + 456;
|
||||
return client;
|
||||
}
|
||||
|
||||
static std::string row(const ClientInfo* client) {
|
||||
char output[68];
|
||||
snprintf(output, sizeof(output), "%02X ", unsigned(client->permissions));
|
||||
for (size_t i = 0; i < PUB_KEY_SIZE; ++i) {
|
||||
snprintf(output + 3 + 2 * i, sizeof(output) - 3 - 2 * i,
|
||||
"%02X", unsigned(client->id.pub_key[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static std::string query(const ClientACL& acl, const char* command, bool local = false) {
|
||||
char reply[160];
|
||||
memset(reply, '#', sizeof(reply));
|
||||
CHECK(mesh::cli::handleACLGet(acl, command, reply, 157, local));
|
||||
CHECK(reply[157] == '#' && reply[158] == '#' && reply[159] == '#');
|
||||
CHECK(strlen(reply) < 157);
|
||||
return reply;
|
||||
}
|
||||
|
||||
static void empty_and_single_entry() {
|
||||
ClientACL acl;
|
||||
CHECK(query(acl, "get acl") == "ACL: empty");
|
||||
CHECK(query(acl, "get acl 1") == "ACL: empty");
|
||||
CHECK(query(acl, "get acl 2") == "Err - ACL page range: 1-1");
|
||||
add(acl, 0, 0);
|
||||
CHECK(query(acl, "get acl") == "ACL: empty");
|
||||
const auto* client = add(acl, 1, 0xA3);
|
||||
CHECK(query(acl, "get acl") == "ACL 1/1\n" + row(client));
|
||||
}
|
||||
|
||||
static void skips_inactive_and_preserves_full_keys() {
|
||||
ClientACL acl;
|
||||
std::vector<std::string> active;
|
||||
for (unsigned i = 0; i < 9; ++i) {
|
||||
auto* client = add(acl, i, i % 2 == 0 ? uint8_t(i + 1) : 0);
|
||||
if (client->permissions) active.push_back(row(client));
|
||||
}
|
||||
CHECK(query(acl, "get acl") == "ACL 1/3\n" + active[0] + "\n" + active[1]);
|
||||
CHECK(query(acl, "get acl 2") == "ACL 2/3\n" + active[2] + "\n" + active[3]);
|
||||
CHECK(query(acl, "get acl 3") == "ACL 3/3\n" + active[4]);
|
||||
CHECK(query(acl, "get acl 4") == "Err - ACL page range: 1-3");
|
||||
}
|
||||
|
||||
static void full_table_pages() {
|
||||
ClientACL acl;
|
||||
for (unsigned i = 0; i < MAX_CLIENTS; ++i) add(acl, i, PERM_ACL_ADMIN);
|
||||
const unsigned pages = (MAX_CLIENTS + 1) / 2;
|
||||
for (unsigned page = 1; page <= pages; ++page) {
|
||||
char command[32], header[32];
|
||||
snprintf(command, sizeof(command), "get acl %u", page);
|
||||
snprintf(header, sizeof(header), "ACL %u/%u", page, pages);
|
||||
std::string expected(header);
|
||||
for (unsigned i = (page - 1) * 2; i < page * 2 && i < MAX_CLIENTS; ++i) {
|
||||
expected += "\n" + row(acl.getClientByIdx(i));
|
||||
}
|
||||
CHECK(query(acl, command) == expected);
|
||||
}
|
||||
}
|
||||
|
||||
static void rejects_bad_pages() {
|
||||
ClientACL acl;
|
||||
add(acl, 0, 3);
|
||||
for (const char* command : {
|
||||
"get acl 0", "get acl -1", "get acl +1", "get acl 1x", "get acl 1 2",
|
||||
"get acl 1.0", "get acl page 1", "get acl 65536", "get acl 4294967296",
|
||||
"get acl 9999999999999999999999999999999999", "get acl 1\nsetperm key 3"}) {
|
||||
CHECK(query(acl, command) == "Err - usage: get acl [page]");
|
||||
}
|
||||
CHECK(query(acl, "get acl 65535") == "Err - ACL page range: 1-1");
|
||||
const auto expected = query(acl, "get acl");
|
||||
for (const char* command : {"get acl ", "get acl\r\n", "get acl\t01 \r\n", "get acl 00000000000001"}) {
|
||||
CHECK(query(acl, command) == expected);
|
||||
}
|
||||
}
|
||||
|
||||
static void matching_and_local_fallback() {
|
||||
ClientACL acl;
|
||||
char reply[157] = "unchanged";
|
||||
for (const char* command : {"", "get", "get ac", "get aclx", "get acl.other", "set acl"}) {
|
||||
CHECK(!mesh::cli::handleACLGet(acl, command, reply, sizeof(reply), false));
|
||||
CHECK(strcmp(reply, "unchanged") == 0);
|
||||
}
|
||||
CHECK(!mesh::cli::handleACLGet(acl, "get acl", reply, sizeof(reply), true));
|
||||
CHECK(strcmp(reply, "unchanged") == 0);
|
||||
CHECK(query(acl, "get acl 1", true) == "ACL: empty");
|
||||
}
|
||||
|
||||
static void bounds_never_truncate_keys() {
|
||||
ClientACL acl;
|
||||
add(acl, 0, 3);
|
||||
add(acl, 1, 3);
|
||||
const auto expected = query(acl, "get acl");
|
||||
for (size_t capacity = 1; capacity <= 160; ++capacity) {
|
||||
char reply[161];
|
||||
memset(reply, '#', sizeof(reply));
|
||||
CHECK(mesh::cli::handleACLGet(acl, "get acl", reply, capacity, false));
|
||||
CHECK(reply[capacity] == '#');
|
||||
CHECK(strlen(reply) < capacity);
|
||||
if (capacity > expected.size()) CHECK(reply == expected);
|
||||
else CHECK(strstr(reply, "ACL 1/1") == nullptr);
|
||||
}
|
||||
char byte = '#';
|
||||
CHECK(mesh::cli::handleACLGet(acl, "get acl", &byte, 0, false));
|
||||
CHECK(byte == '#');
|
||||
}
|
||||
|
||||
static void listing_does_not_mutate_acl() {
|
||||
FakeFilesystem fs;
|
||||
ClientACL acl;
|
||||
mesh::LocalIdentity self;
|
||||
acl.load(&fs, self);
|
||||
for (unsigned i = 0; i < 5; ++i) add(acl, i, uint8_t(i));
|
||||
const auto before = acl;
|
||||
const auto writes = fs.bytes_written;
|
||||
for (const char* command : {"get acl", "get acl 2", "get acl 3", "get acl 0"}) {
|
||||
query(acl, command);
|
||||
}
|
||||
CHECK(acl.getNumClients() == before.getNumClients());
|
||||
for (int i = 0; i < acl.getNumClients(); ++i) {
|
||||
CHECK(memcmp(acl.getClientByIdx(i), before.getClientByIdx(i), sizeof(ClientInfo)) == 0);
|
||||
}
|
||||
CHECK(fs.bytes_written == writes);
|
||||
}
|
||||
|
||||
static void actual_role_dispatch_handles_radio_and_local() {
|
||||
ClientACL acl;
|
||||
auto* admin = add(acl, 0, PERM_ACL_ADMIN);
|
||||
add(acl, 1, PERM_ACL_READ_WRITE);
|
||||
const auto expected = query(acl, "get acl");
|
||||
for (auto handler : {repeaterCommand, roomCommand, sensorCommand}) {
|
||||
for (uint32_t timestamp : {0U, 12345U}) {
|
||||
for (const char* input : {"get acl", "get acl 1", "ab|GET acl", "ab|get acl 1"}) {
|
||||
char command[80], reply[161];
|
||||
strcpy(command, input);
|
||||
memset(reply, '#', sizeof(reply));
|
||||
mesh::console.output.clear();
|
||||
handler(acl, admin, timestamp, command, reply);
|
||||
CHECK(mesh::console.output.empty());
|
||||
CHECK(reply[160] == '#');
|
||||
CHECK(strlen(reply) < 160);
|
||||
CHECK(std::string(reply) == (input[2] == '|' ? "ab|" + expected : expected));
|
||||
}
|
||||
}
|
||||
char command[] = "get acl", reply[160] = {};
|
||||
mesh::console.output.clear();
|
||||
handler(acl, nullptr, 0, command, reply);
|
||||
CHECK(reply[0] == 0);
|
||||
CHECK(mesh::console.output.find("ACL:") == 0);
|
||||
CHECK(mesh::console.output.find(row(admin)) != std::string::npos);
|
||||
strcpy(command, "get acl");
|
||||
char paged[] = "get acl 1";
|
||||
mesh::console.output.clear();
|
||||
handler(acl, nullptr, 0, paged, reply);
|
||||
CHECK(mesh::console.output.empty());
|
||||
CHECK(reply == expected);
|
||||
}
|
||||
}
|
||||
|
||||
static void repeater_delegation_stays_denied() {
|
||||
ClientACL acl;
|
||||
auto* sender = add(acl, 0, 3);
|
||||
for (unsigned permissions = 0; permissions <= 255; ++permissions) {
|
||||
sender->permissions = uint8_t(permissions);
|
||||
for (const char* input : {"get acl", "get acl 2", "xy|get acl", "xy|get acl 2"}) {
|
||||
char command[32], reply[160] = {};
|
||||
strcpy(command, input);
|
||||
mesh::console.output.clear();
|
||||
repeaterCommand(acl, sender, 0, command, reply);
|
||||
CHECK(mesh::console.output.empty());
|
||||
const char* body = input[2] == '|' ? reply + 3 : reply;
|
||||
if ((permissions & PERM_ACL_ROLE_MASK) != PERM_ACL_ADMIN) {
|
||||
CHECK(strcmp(body, "Err - not permitted") == 0);
|
||||
} else {
|
||||
CHECK(strstr(body, "not permitted") == nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
empty_and_single_entry();
|
||||
skips_inactive_and_preserves_full_keys();
|
||||
full_table_pages();
|
||||
rejects_bad_pages();
|
||||
matching_and_local_fallback();
|
||||
bounds_never_truncate_keys();
|
||||
listing_does_not_mutate_acl();
|
||||
actual_role_dispatch_handles_radio_and_local();
|
||||
repeater_delegation_stays_denied();
|
||||
puts("9 ACL CLI checks passed");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the real ACL page handler, ACL storage, and role dispatch branches."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/client_acl_cli"
|
||||
ACL_MOCKS = ROOT / "test/fixtures/client_acl_spiffs/mocks"
|
||||
ROLES = {
|
||||
"repeater": ROOT / "examples/simple_repeater/MyMesh.cpp",
|
||||
"room": ROOT / "examples/simple_room_server/MyMesh.cpp",
|
||||
"sensor": ROOT / "examples/simple_sensor/SensorMesh.cpp",
|
||||
}
|
||||
|
||||
|
||||
def role_handler(role, source):
|
||||
# Compile the actual timestamp normalization, prefix handling, manager
|
||||
# guard, and both ACL branches. Unrelated radio/board handlers are omitted.
|
||||
owner = "SensorMesh" if role == "sensor" else "MyMesh"
|
||||
body = extract_braced(source, f"void {owner}::handleCommand(uint32_t sender_timestamp,")
|
||||
zero_guard = re.search(r"if \([^\n]+sender_timestamp == 0\) sender_timestamp = 1;", body).group()
|
||||
prefix = extract_braced(body, "if (strlen(command) > 4 && command[2] == '|')")
|
||||
paged = extract_braced(body, "if (mesh::cli::handleACLGet(")
|
||||
local = extract_braced(body, 'if (sender_timestamp == 0 && strcmp(command, "get acl") == 0)')
|
||||
# The room source opens its next conditional branch's preprocessor guard
|
||||
# immediately before the closing brace of the local ACL branch.
|
||||
local = re.sub(r"\n#if defined\(WITH_MQTT_NEIGHBORS\)\n\s*}$", "\n}", local)
|
||||
guard = ""
|
||||
if role == "repeater":
|
||||
guard_start = body.rindex("if (sender && !sender->isAdmin())", 0,
|
||||
body.index("mesh::cli::handleACLGet("))
|
||||
guard = extract_braced(body[guard_start:], "if (sender && !sender->isAdmin())")
|
||||
return f"""
|
||||
static void {role}Command(ClientACL& acl, ClientInfo* sender,
|
||||
uint32_t sender_timestamp, char* command, char* reply) {{
|
||||
const int gpio_client_index = sender == nullptr ? -1 : 0;
|
||||
(void)gpio_client_index;
|
||||
{zero_guard}
|
||||
while (*command == ' ') ++command;
|
||||
{prefix}
|
||||
mesh::cli::normalizeCommandVerb(command);
|
||||
{guard}
|
||||
{paged} else {local} else strcpy(reply, "unhandled");
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
class ClientAclCliTest(unittest.TestCase):
|
||||
def test_executable_pages_and_role_dispatch(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
self.skipTest("a host C++17 compiler is required")
|
||||
sources = {role: path.read_text() for role, path in ROLES.items()}
|
||||
utils = (ROOT / "src/Utils.cpp").read_text()
|
||||
hex_chars = re.search(r"static const char hex_chars\[\].*;", utils).group()
|
||||
generated = "namespace mesh {\n" + hex_chars + "\n"
|
||||
generated += extract_braced(utils, "void Utils::toHex(") + "\n"
|
||||
generated += extract_braced(utils, "void Utils::printHex(") + "\n}\n"
|
||||
for signature in (
|
||||
"static bool commandFamilyMatches(", "static bool isCommonManagerReadOnlyAllowed(",
|
||||
"static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(",
|
||||
):
|
||||
generated += extract_braced(sources["repeater"], signature) + "\n"
|
||||
for role, source in sources.items():
|
||||
generated += role_handler(role, source)
|
||||
with tempfile.TemporaryDirectory(prefix=".tmp-acl-cli-", dir=ROOT) as directory:
|
||||
work = Path(directory)
|
||||
# Only declarations are mocked; both hex conversion definitions
|
||||
# above come from production Utils.cpp, with the real hex alphabet.
|
||||
(work / "Utils.h").write_text("""#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
class Stream;
|
||||
namespace mesh { class Utils { public:
|
||||
static void toHex(char*, const uint8_t*, size_t);
|
||||
static void printHex(Stream&, const uint8_t*, size_t);
|
||||
}; }
|
||||
""")
|
||||
(work / "production.h").write_text(generated)
|
||||
for clients in (32, 256):
|
||||
with self.subTest(max_clients=clients):
|
||||
binary = work / f"acl-cli-{clients}"
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-DESP32=1",
|
||||
"-DESP32_PLATFORM=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1",
|
||||
f"-DMAX_CLIENTS={clients}", f"-I{work}", f"-I{ACL_MOCKS}",
|
||||
f"-I{ROOT / 'src'}", str(FIXTURE / "test_client_acl_cli.cpp"),
|
||||
"-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("9 ACL CLI checks passed", checked.stdout)
|
||||
|
||||
def test_remote_dispatch_stays_behind_existing_admin_guards(self):
|
||||
for role, path in ROLES.items():
|
||||
with self.subTest(role=role):
|
||||
source = path.read_text()
|
||||
handler = extract_braced(source, f"void {'SensorMesh' if role == 'sensor' else 'MyMesh'}::handleCommand(uint32_t sender_timestamp,")
|
||||
self.assertLess(handler.index("mesh::cli::handleACLGet("),
|
||||
handler.index('if (sender_timestamp == 0 && strcmp(command, "get acl") == 0)'))
|
||||
self.assertIn("reply, 160 - 3,", handler)
|
||||
if role == "repeater":
|
||||
self.assertLess(handler.index("if (sender && !sender->isAdmin())"),
|
||||
handler.index("mesh::cli::handleACLGet("))
|
||||
elif role == "room":
|
||||
receiver = extract_braced(source, "void MyMesh::onPeerDataRecv(")
|
||||
admin = extract_braced(receiver, "if (client->isAdmin())")
|
||||
self.assertIn("handleCommand(sender_timestamp,", admin)
|
||||
else:
|
||||
admin = extract_braced(source, "if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && from->isAdmin())")
|
||||
self.assertIn("handleCommand(sender_timestamp,", admin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -82,11 +82,15 @@ static bool apply_actual_receive_cache_gate(const char* command, bool cache_hit,
|
||||
null_sender = extract_braced(header, "void handleCommand(uint32_t sender_timestamp, char* command, char* reply)")
|
||||
self.assertIn("handleCommand(sender_timestamp, NULL, command, reply)", null_sender)
|
||||
console_start = main.index("if (line_complete)")
|
||||
ethernet_start = main.index("if (ethernet_read_line(", console_start)
|
||||
ethernet_gate = "if (!the_mesh.hasPendingLocalOutput() && ethernet_read_line("
|
||||
ethernet_start = main.index(ethernet_gate, console_start)
|
||||
self.assertIn("the_mesh.handleUsbCommand(command, reply)", main[console_start:ethernet_start])
|
||||
ethernet = extract_braced(main, "if (ethernet_read_line(")
|
||||
ethernet = extract_braced(main, ethernet_gate)
|
||||
self.assertNotIn("handleUsbCommand", ethernet)
|
||||
self.assertIn("the_mesh.handleCommand(0, ethernet_command, reply)", ethernet)
|
||||
self.assertIn("the_mesh.handleLocalCommand(ethernet_command, reply, ethernet_client)", ethernet)
|
||||
local = extract_braced(header, "void handleLocalCommand(")
|
||||
self.assertIn("handleCommand(0, command, reply)", local)
|
||||
self.assertNotIn("handleUsbCommand", local)
|
||||
# No web/internal callback is allowed to adopt the physical entry point.
|
||||
cpp = SOURCE.read_text(encoding="utf-8")
|
||||
self.assertNotIn("handleUsbCommand(", cpp)
|
||||
@@ -118,7 +122,11 @@ static bool apply_actual_receive_cache_gate(const char* command, bool cache_hit,
|
||||
handler = extract_braced(source, "void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender,")
|
||||
recovery = handler.index("handleReplayResetCommand(sender, command, reply, usb_origin)")
|
||||
self.assertLess(recovery, handler.index("_cli.handleCommand("))
|
||||
self.assertNotIn("sender_timestamp == 0", handler[:recovery])
|
||||
# A remote zero timestamp is normalized so it cannot enter a local
|
||||
# streaming handler. It must not grant USB recovery authorization.
|
||||
normalization = "if (sender != nullptr && sender_timestamp == 0) sender_timestamp = 1;"
|
||||
self.assertIn(normalization, handler[:recovery])
|
||||
self.assertNotIn("sender_timestamp == 0", handler[:recovery].replace(normalization, ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user