diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7d958997..ee23d548 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -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 `](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 `](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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index eb0c7b1f..e1914144 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` - 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 /` 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. --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4ed16397..5b518cd5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -6,6 +6,7 @@ #include #include // for qsort() #include +#include #include #include #include @@ -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) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 862fb8f7..d68456cb 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -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) { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index a57c8db4..7fed6d00 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,5 +1,6 @@ #include "SensorMesh.h" #include +#include #include #include #include @@ -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:"); diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 38b3342a..877fa5c9 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -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]; } }; diff --git a/src/helpers/ClientACLCLI.h b/src/helpers/ClientACLCLI.h new file mode 100644 index 00000000..92c77485 --- /dev/null +++ b/src/helpers/ClientACLCLI.h @@ -0,0 +1,81 @@ +#pragma once + +#include "ClientACL.h" +#include +#include +#include + +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 diff --git a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp new file mode 100644 index 00000000..eec7f660 --- /dev/null +++ b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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"); +} diff --git a/test/test_client_acl_cli.py b/test/test_client_acl_cli.py new file mode 100644 index 00000000..9121b001 --- /dev/null +++ b/test/test_client_acl_cli.py @@ -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 +#include +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() diff --git a/test/test_replay_reset_integration.py b/test/test_replay_reset_integration.py index 9babc3e0..32e71b39 100644 --- a/test/test_replay_reset_integration.py +++ b/test/test_replay_reset_integration.py @@ -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__":