diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5ebf0a2e1..b4e4f8703 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -673,6 +673,9 @@ jobs: python3 -B test/test_client_acl_role.py -v python3 -B test/test_client_acl_metadata.py -v python3 -B test/test_client_acl_cli.py -v + python3 -B test/test_guest_cli.py -v + python3 -B test/test_guest_login_settings.py -v + python3 -B test/test_repeater_guest_access.py -v python3 -B test/test_client_acl_response.py -v - name: Setup Node for official app compatibility tests diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 978d74a48..f87d9fd03 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2384,6 +2384,41 @@ replace radio ACL permissions. --- +#### Enable or disable repeater guest logins + +**Availability:** Repeater firmware. Local administrative consoles and authenticated +remote admins can use these commands. + +```text +get guest +set guest on +set guest off +``` + +**Default:** `off`, including when upgrading from firmware without this setting. +The choice is saved and takes effect without a reboot. `set guest off` also +removes existing guest sessions immediately. Turning it back on requires guests +to log in again; admin and assigned ACL access are unaffected. + +When enabled, guests must supply the configured `guest.password`. Its default is +blank, so enabling guests without setting a password permits passwordless guest +access. A guest can read repeater status and basic telemetry but cannot run +administrative commands. This setting does not disable ordinary packet forwarding +or public discovery queries. + +For example: + +```text +set guest.password a-private-code +set guest on +get guests +set guest off +``` + +Room-server login settings are unchanged. + +--- + #### View or change this node's guest password **Usage:** - `get guest.password` @@ -2396,6 +2431,10 @@ replace radio ACL permissions. **Default:** `` +Repeater guest logins must also be enabled with `set guest on`. Changing this +password takes effect immediately; use `set guest off` to remove existing guest +sessions. Setting the password to `off` makes it the literal password `off`. + --- #### View or change this node's owner info @@ -4279,6 +4318,28 @@ secrets, and does not change the ACL. --- +#### View repeater guest sessions + +**Usage:** + +- `get guests` - first page +- `get guests ` - a numbered page, starting at 1 + +Available on repeaters through local administrative consoles or an authenticated +remote admin session. Delegated region/filter managers and guest clients cannot +read this list. + +Each page starts with `Guests / ()` and shows one guest's full +public key followed by `last=`. An empty list returns +`Guests: empty`. A `last=0` value can occur before the node's clock is set. + +These are live RAM entries, separate from the persistent ACL shown by `get acl`. +They indicate an accepted session, not whether the guest is currently online. +Entries can move between pages as clients change. A reboot or `set guest off` +clears them. + +--- + #### Recover a repeater's future-dated replay timestamp This is an explicit recovery operation after correcting a bad clock, not a diff --git a/docs/fleet_management.md b/docs/fleet_management.md index 9eb889887..c4ef8494f 100644 --- a/docs/fleet_management.md +++ b/docs/fleet_management.md @@ -303,6 +303,8 @@ Reboot or changing enrollment discards the partial transfers. | UTC clock | `clock`, `clock sync`, `time ` | | Profile diagnostics | `get radio2.status`, `get radio2.timing` | | Crossover | `get radio2.cross`, `set radio2.cross ` | +| Repeater guest login | `get guest`, `set guest on|off` | +| Repeater guest sessions | `get guests [page]` | | Filter rules | `get/set/del flood.filter[.n]`, `flood.rule[.n]` | | Path blacklist | `get/set/del flood.filter.blacklist[.n]` | | Channel moderation | `get/set/del flood.moderation[.n]` | @@ -314,6 +316,10 @@ Each role still checks whether it supports the requested setting. Immediate primary radio changes, identity export, passwords, ACL edits, filesystem operations, firmware installation, reboot, and unrestricted CLI are outside fleet capability. +Repeater guest login defaults to off. `set guest off` saves that choice and +clears existing guest sessions immediately. Enabling it uses the existing +guest password; this switch does not change any password or assigned ACL role. + `set radio2 mediamesh` saves 911.150 MHz, 250 kHz, SF7, CR 4/5, `rxtx`, with automatic scan-aware preamble. `mediamesh-range` uses 125 kHz instead. These presets preserve the primary profile and crossing policy; new diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 9eaac5c97..217aa0a38 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -108,6 +108,7 @@ the corresponding feature: | `get wifi.pwd` | Stored WiFi password on WiFi builds | | `get mqttN.password`, `get mqttN.token` | Stored credentials for MQTT slot N | | `get acl`, `log` | Infrastructure ACL or captured packet log; these stores do not exist on Companion | +| `get guest`, `set guest on`, `set guest off`, `get guests [page]` | Repeater guest-login control and current guest sessions | The command text is the same for direct connections. A binary Companion app can send `0x42` (`CMD_RUN_CLI_COMMAND`) followed by the ASCII command, without @@ -635,6 +636,19 @@ cmd ver cmd get radio ``` +Repeater guest logins default to off. An admin can use `cmd set guest on` to +enable them or `cmd set guest off` to disable them and remove current guest +sessions immediately. These settings are saved and need no reboot. When enabled, +the configured `guest.password` applies; its blank default permits passwordless +guest access. Admin and assigned ACL access are unaffected. Room-server login +settings are unchanged. + +Use `cmd get guests` or `cmd get guests 2` from an admin session to inspect the +repeater's guest entries. Each page shows one full public key and its last +activity time in UTC epoch seconds. Guests are held in RAM, so this is a list of +accepted sessions rather than proof that a device is currently reachable. They +are cleared by a reboot or `set guest off` and remain separate from `get acl`. + The exact commands and permissions depend on the target firmware. `cmd` does not run a command on the local Companion; it sends the text over LoRa to the selected node. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 918c5a431..702cb01ba 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -38,6 +38,7 @@ #include #endif #include +#include #include #include #include @@ -573,8 +574,12 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr ClientInfo* existing_client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); const bool admin_password = - strcmp((char *)data, _prefs.password) == 0; - ClientInfo* client = admin_password ? NULL : existing_client; + _prefs.password[0] != 0 && strcmp((char *)data, _prefs.password) == 0; + // Only explicitly authorized roles may reconnect without a password. + // A cached guest identity must still pass the current guest-login policy. + ClientInfo* client = !admin_password && existing_client != NULL + && (existing_client->permissions & PERM_ACL_ROLE_MASK) != PERM_ACL_GUEST + ? existing_client : NULL; uint8_t perms; if (client != NULL) { // A preauthorized ACL identity is authenticated by the anonymous packet's @@ -588,7 +593,8 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr } if (admin_password) { // valid admin password bypasses ACL (allows upgrade) perms = PERM_ACL_ADMIN; - } else if (strcmp((char *)data, _prefs.guest_password) == 0) { // check guest password + } else if (guest_login.enabled() + && strcmp((char *)data, _prefs.guest_password) == 0) { perms = PERM_ACL_GUEST; } else { #if MESH_DEBUG @@ -764,6 +770,8 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t *payload, size_t payload_len, size_t reply_capacity) { if (sender == NULL || payload_len == 0 || reply_capacity < 4) return 0; + if (!guest_login.enabled() + && (sender->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) return 0; if (reply_capacity > mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY) { reply_capacity = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY; } @@ -2813,7 +2821,10 @@ int MyMesh::searchPeersByHash(const uint8_t *hash) { } #endif for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + const auto* client = acl.getClientByIdx(i); + if (!guest_login.enabled() + && (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) continue; + if (client->id.isHashMatch(hash)) { matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) } } @@ -2913,6 +2924,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, return; } ClientInfo* client = acl.getClientByIdx(i); + if (!guest_login.enabled() + && (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) return; #if defined(WITH_MQTT_NEIGHBORS) // A neighbour that IS an ACL client resolves to a normal index above, so a // scope-query response from it lands here -- match it against the overlay. @@ -3440,6 +3453,8 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); auto client = acl.getClientByIdx(i); + if (!guest_login.enabled() + && (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) return false; // A flood login's reciprocal PATH is direct and has no embedded payload // (decoded as the reserved 0x0F extra type). Retain that one independently @@ -3871,6 +3886,8 @@ void MyMesh::begin(FILESYSTEM *fs) { mesh::hilStartupTrace("mesh_acl_begin"); acl.load(_fs, self_id); + guest_login.begin(_fs); + if (!guest_login.enabled()) acl.removeGuestClients(); mesh::hilStartupTrace("mesh_acl_ready"); // TODO: key_store.begin(); region_map.load(_fs); @@ -5447,6 +5464,27 @@ void MyMesh::dumpLogFile() { void MyMesh::dumpLogFile() { } #endif +bool MyMesh::handleGuestCommand(const char* command, char* reply, size_t capacity) { + const bool get = strncmp(command, "get guest", 9) == 0; + const bool set = strncmp(command, "set guest", 9) == 0; + if ((!get && !set) || (command[9] && command[9] != ' ' && command[9] != '\t')) return false; + const char* value = command + 9; + while (*value == ' ' || *value == '\t') ++value; + if ((get && *value) || (set && strcmp(value, "on") && strcmp(value, "off"))) { + snprintf(reply, capacity, "Err - use get guest or set guest on|off"); + } else if (get) { + snprintf(reply, capacity, "> %s%s", guest_login.enabled() ? "on" : "off", + guest_login.healthy() ? "" : " (settings unavailable)"); + } else if (!guest_login.setEnabled(strcmp(value, "on") == 0)) { + if (!guest_login.enabled()) acl.removeGuestClients(); + snprintf(reply, capacity, "Err - guest setting save unconfirmed; reboot may differ"); + } else { + if (!guest_login.enabled()) acl.removeGuestClients(); + snprintf(reply, capacity, "OK - guest %s", guest_login.enabled() ? "on" : "off"); + } + return true; +} + void MyMesh::printAclSerial() { #if MESH_USB_CONSOLE_COOPERATIVE if (hasPendingSerialOutput()) { @@ -12538,6 +12576,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * return; } } + if (handleGuestCommand(command, reply, 157) + || mesh::cli::handleGuestsGet(acl, command, reply, 157)) return; _cli.handleCommand(sender_timestamp, command, reply); #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0; @@ -12558,6 +12598,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } } + if (handleGuestCommand(command, reply, 157) + || mesh::cli::handleGuestsGet(acl, command, reply, 157)) return; + if (strncmp(command, "backup prv.key", 14) == 0 && (command[14] == 0 || command[14] == ' ')) { #if ENABLE_PRIVATE_KEY_EXPORT diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 76dfbe5ab..21fb5ac9c 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -7,6 +7,7 @@ #include #include #include +#include #if defined(ENABLE_OTA) #include #endif @@ -381,6 +382,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks RadioActivityWindow _activity; #endif NodePrefs _prefs; + mesh::GuestLoginSettings guest_login; + bool handleGuestCommand(const char* command, char* reply, size_t capacity); ClientACL acl; CommonCLI _cli; #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 6b42f3b2e..2c4b1e734 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -895,6 +895,22 @@ bool ClientACL::clear() { return true; } +int ClientACL::removeGuestClients() { + int retained = 0; + const int previous_count = num_clients; + for (int i = 0; i < previous_count; ++i) { + if ((clients[i].permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) continue; + if (retained != i) clients[retained] = clients[i]; + ++retained; + } + const int removed = previous_count - retained; + if (removed != 0) { + memset(clients + retained, 0, sizeof(ClientInfo) * size_t(removed)); + num_clients = retained; + } + return removed; +} + ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) { if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return NULL; for (int i = 0; i < num_clients; i++) { diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index dc64e71bd..73d798816 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -124,6 +124,7 @@ public: void protectExplicitPermissions(bool protect = true) { protect_explicit_permissions = protect; } bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); bool clear(); + int removeGuestClients(); // transient sessions only; never writes flash // Authenticate the sender/password first, then call this before allocating // or mutating a client. A true result means any required replay high-water diff --git a/src/helpers/FleetCommand.cpp b/src/helpers/FleetCommand.cpp index 665ca8cf8..8073bfb06 100644 --- a/src/helpers/FleetCommand.cpp +++ b/src/helpers/FleetCommand.cpp @@ -232,7 +232,7 @@ bool rowKey(const char* key, size_t length, const char* family) { } enum class CommandKind : uint8_t { - Filter, Row, Advert, Schedule, SecondarySchedule, Radio, RadioStatus, Cross, Setting + Filter, Row, Advert, Schedule, SecondarySchedule, Radio, RadioStatus, Cross, Guest, Guests, Setting }; struct CommandFamily { @@ -266,6 +266,8 @@ const CommandFamily* commandFamily(const char* key, size_t length) { {"radio2.status", CommandKind::RadioStatus}, {"radio2.timing", CommandKind::RadioStatus}, {"radio2.cross", CommandKind::Cross}, + {"guest", CommandKind::Guest}, + {"guests", CommandKind::Guests}, {"flood.channel.data", CommandKind::Setting}, {"flood.channel.data.hops", CommandKind::Setting}, {"flood.max", CommandKind::Setting}, @@ -592,6 +594,14 @@ bool FleetCommand::commandAllowed(const char* command) { return true; } if (clear) return false; + if (kind == CommandKind::Guest) { + return get ? !*arguments : set && (!strcmp(arguments, "on") || !strcmp(arguments, "off")); + } + if (kind == CommandKind::Guests) { + uint32_t page = 0; + return get && (!*arguments || (arguments[0] >= '1' && arguments[0] <= '9' + && boundedIntegerToken(arguments, strlen(arguments), 65535, page))); + } const bool scheduled_secondary = kind == CommandKind::SecondarySchedule; if (kind == CommandKind::Schedule || scheduled_secondary) { diff --git a/src/helpers/GuestCLI.h b/src/helpers/GuestCLI.h new file mode 100644 index 000000000..8d0ac0bce --- /dev/null +++ b/src/helpers/GuestCLI.h @@ -0,0 +1,84 @@ +#pragma once + +#include "ClientACL.h" +#include +#include +#include + +namespace mesh { +namespace cli { + +inline bool isLiveGuest(const ClientInfo& client) { + if ((client.permissions & PERM_ACL_ROLE_MASK) != PERM_ACL_GUEST) return false; + uint8_t key_bits = 0, secret_bits = 0; + for (size_t i = 0; i < PUB_KEY_SIZE; ++i) { + key_bits |= client.id.pub_key[i]; + secret_bits |= client.shared_secret[i]; + } + return key_bits != 0 && secret_bits != 0; +} + +// Call after the repeater's admin authorization check. One complete public +// key per page leaves room for activity information in a radio CLI reply. +inline bool handleGuestsGet(const ClientACL& acl, const char* command, + char* reply, size_t capacity) { + static const char prefix[] = "get guests"; + const size_t prefix_len = sizeof(prefix) - 1; + if (command == nullptr || 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 (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 guests [page]"); + return true; + } + + unsigned total = 0; + for (int i = 0; i < acl.getNumClients(); ++i) { + if (isLiveGuest(*acl.getClientByIdx(i))) ++total; + } + if (total == 0 && page == 1) { + snprintf(reply, capacity, "Guests: empty"); + return true; + } + const unsigned pages = total == 0 ? 1 : total; + if (page > pages) { + snprintf(reply, capacity, "Err - guest page range: 1-%u", pages); + return true; + } + + unsigned index = 0; + for (int i = 0; i < acl.getNumClients(); ++i) { + const auto* client = acl.getClientByIdx(i); + if (!isLiveGuest(*client) || ++index != page) continue; + char key[PUB_KEY_SIZE * 2 + 1]; + Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); + const int needed = snprintf(nullptr, 0, "Guests %u/%u (%u)\n%s last=%lu", + page, pages, total, key, + static_cast(client->last_activity)); + // A small caller buffer must never turn a complete key into a prefix. + if (needed < 0 || size_t(needed) >= capacity) { + snprintf(reply, capacity, "Err - guest reply too small"); + } else { + snprintf(reply, capacity, "Guests %u/%u (%u)\n%s last=%lu", + page, pages, total, key, + static_cast(client->last_activity)); + } + return true; + } + return true; +} + +} // namespace cli +} // namespace mesh diff --git a/src/helpers/GuestLoginSettings.cpp b/src/helpers/GuestLoginSettings.cpp new file mode 100644 index 000000000..ef0285d75 --- /dev/null +++ b/src/helpers/GuestLoginSettings.cpp @@ -0,0 +1,83 @@ +#include "GuestLoginSettings.h" +#include "CheckedSettingsFile.h" +#include "FilePresence.h" +#include "PersistentStoreFormat.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif + +namespace mesh { +namespace { +constexpr char Path[] = "/guest_login"; +constexpr size_t ImageSize = 12; + +void encode(uint8_t (&image)[ImageSize], bool enabled) { + memset(image, 0, sizeof(image)); + memcpy(image, "GLG1", 4); + image[4] = uint8_t(enabled); + storage::writeLE32(image + 8, storage::updateCRC32(0xffffffff, image, 8)); +} + +bool valid(const uint8_t (&image)[ImageSize]) { + return memcmp(image, "GLG1", 4) == 0 && image[4] <= 1 + && image[5] == 0 && image[6] == 0 && image[7] == 0 + && storage::readLE32(image + 8) + == storage::updateCRC32(0xffffffff, image, 8); +} +} + +bool GuestLoginSettings::begin(FILESYSTEM* fs) { + fs_ = fs; + enabled_ = false; + healthy_ = false; + if (!fs_) return false; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs_, Path, filePresence)) return false; +#endif + bool present = false; + if (!filePresence(fs_, Path, present)) return false; + if (!present) { + healthy_ = true; + return true; + } + uint8_t image[ImageSize]; + const auto result = storage::readFixedSettingsFileResult(fs_, Path, image, sizeof(image)); + if (result == storage::FixedSettingsRead::Unreadable) return false; + // An explicit administrator setting may replace a complete malformed record; + // an I/O error must first be reconciled by a successful reload. + healthy_ = true; + if (result != storage::FixedSettingsRead::Complete || !valid(image)) return false; + enabled_ = image[4] != 0; + return true; +} + +bool GuestLoginSettings::setEnabled(bool enabled) { + if (!healthy_ || !fs_) return false; + uint8_t image[ImageSize]; + encode(image, enabled); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter writer(fs_, Path); + const bool wrote = writer && writer.write(image, sizeof(image)) == sizeof(image); + const bool saved = writer.commit(wrote); + if (writer.outcome() == AtomicFileWriter::Outcome::Indeterminate) healthy_ = false; + if (!saved) return false; +#else + ContactFileTransaction writer(fs_, Path, filePresence); + if (!writer || writer.write(image, sizeof(image)) != sizeof(image)) { + if (writer.outcome() == ContactFileTransaction::Outcome::Indeterminate) healthy_ = false; + return false; + } + writer.commit(); + if (writer.outcome() == ContactFileTransaction::Outcome::NotCommitted) return false; +#endif + uint8_t verified[ImageSize]; + healthy_ = storage::readFixedSettingsFile(fs_, Path, verified, sizeof(verified)) + && memcmp(image, verified, sizeof(image)) == 0; + if (!healthy_) return false; + enabled_ = enabled; + return true; +} + +} // namespace mesh diff --git a/src/helpers/GuestLoginSettings.h b/src/helpers/GuestLoginSettings.h new file mode 100644 index 000000000..44006b8f7 --- /dev/null +++ b/src/helpers/GuestLoginSettings.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace mesh { + +// Stored separately so upgrading existing repeaters leaves guest access off. +class GuestLoginSettings { + FILESYSTEM* fs_ = nullptr; + bool enabled_ = false; + bool healthy_ = false; + +public: + // Missing settings are a valid, unwritten default. Invalid records fail closed. + bool begin(FILESYSTEM* fs); + bool enabled() const { return healthy_ && enabled_; } + bool healthy() const { return healthy_; } + // Changes take effect only after the published record is read back. + bool setEnabled(bool enabled); +}; + +} // namespace mesh diff --git a/test/fixtures/client_acl_response/test_client_acl_response.cpp b/test/fixtures/client_acl_response/test_client_acl_response.cpp index 99c3e7c6b..e689c5d47 100644 --- a/test/fixtures/client_acl_response/test_client_acl_response.cpp +++ b/test/fixtures/client_acl_response/test_client_acl_response.cpp @@ -7,6 +7,8 @@ #define REQ_TYPE_GET_ACCESS_LIST 0x05 #define SERVER_RESPONSE_DELAY 300 +#define PERM_ACL_ROLE_MASK 7 +#define PERM_ACL_GUEST 0 namespace mesh { struct Identity { @@ -71,6 +73,7 @@ struct Clock : mesh::RTCClock { class MyMesh : public mesh::Mesh { public: uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; + struct { bool enabled() const { return false; } } guest_login; ACL acl; ClientInfo sender; Clock clock; diff --git a/test/fixtures/guest_cli/test_guest_cli.cpp b/test/fixtures/guest_cli/test_guest_cli.cpp new file mode 100644 index 000000000..d6dc93c8c --- /dev/null +++ b/test/fixtures/guest_cli/test_guest_cli.cpp @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include + +static unsigned setting_calls = 0; +static bool handleGuestCommand(const char* command, char* reply, size_t capacity) { + if (strcmp(command, "get guest") != 0 && strcmp(command, "set guest on") != 0 + && strcmp(command, "set guest off") != 0) return false; + ++setting_calls; + snprintf(reply, capacity, "guest setting handled"); + return true; +} + +#include "production.h" + +#define CHECK(condition) do { if (!(condition)) { \ + std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); std::exit(1); \ +} } while (0) + +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); + 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 key[PUB_KEY_SIZE * 2 + 1], activity[32]; + mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); + snprintf(activity, sizeof(activity), " last=%lu", + static_cast(client->last_activity)); + return std::string(key) + activity; +} + +static std::string query(const ClientACL& acl, const char* command) { + char reply[160]; + memset(reply, '#', sizeof(reply)); + CHECK(mesh::cli::handleGuestsGet(acl, command, reply, 157)); + CHECK(reply[157] == '#' && reply[158] == '#' && reply[159] == '#'); + CHECK(strlen(reply) < 157); + return reply; +} + +static void empty_and_guest_only() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + CHECK(query(acl, "get guests") == "Guests: empty"); + CHECK(query(acl, "get guests 1") == "Guests: empty"); + CHECK(query(acl, "get guests 2") == "Err - guest page range: 1-1"); + add(acl, 0, PERM_ACL_ADMIN); + CHECK(query(acl, "get guests") == "Guests: empty"); + const auto* guest = add(acl, 1, PERM_ACL_GUEST); + CHECK(query(acl, "get guests") == "Guests 1/1 (1)\n" + row(guest)); + CHECK(query(acl, "get guests 1") == query(acl, "get guests")); +} + +static void skips_inactive_and_other_roles() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + add(acl, 0, PERM_ACL_ADMIN); + const auto expected = "Guests 1/2 (2)\n" + row(add(acl, 1, 0xA0)); + add(acl, 2, PERM_ACL_READ_ONLY); + add(acl, 3, PERM_ACL_READ_WRITE); + add(acl, 4, PERM_ACL_REGION_MGR); + add(acl, 5, PERM_ACL_FILTER_MGR); + add(acl, 6, 6); + add(acl, 7, 7); + auto* clockless = add(acl, 8, PERM_ACL_GUEST); + clockless->last_activity = 0; + const auto clockless_row = row(clockless); + memset(add(acl, 9, PERM_ACL_GUEST)->shared_secret, 0, PUB_KEY_SIZE); + memset(add(acl, 10, PERM_ACL_GUEST)->id.pub_key, 0, PUB_KEY_SIZE); + CHECK(query(acl, "get guests") == expected); + CHECK(query(acl, "get guests 2") == "Guests 2/2 (2)\n" + clockless_row); + CHECK(query(acl, "get guests 3") == "Err - guest page range: 1-2"); +} + +static void full_table_pages_preserve_full_keys() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + for (unsigned i = 0; i < MAX_CLIENTS; ++i) add(acl, i, PERM_ACL_GUEST); + acl.getClientByIdx(MAX_CLIENTS - 1)->last_activity = UINT32_MAX; + for (unsigned page = 1; page <= MAX_CLIENTS; ++page) { + char command[32], header[48]; + snprintf(command, sizeof(command), "get guests %u", page); + snprintf(header, sizeof(header), "Guests %u/%u (%u)\n", page, + unsigned(MAX_CLIENTS), unsigned(MAX_CLIENTS)); + CHECK(query(acl, command) == header + row(acl.getClientByIdx(page - 1))); + } +} + +static void rejects_bad_pages() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + add(acl, 0, PERM_ACL_GUEST); + for (const char* command : { + "get guests 0", "get guests -1", "get guests +1", "get guests 1x", + "get guests 1 2", "get guests 1.0", "get guests page 1", + "get guests 65536", "get guests 4294967296", + "get guests 99999999999999999999999999", "get guests 1\nset guest off"}) { + CHECK(query(acl, command) == "Err - usage: get guests [page]"); + } + CHECK(query(acl, "get guests 65535") == "Err - guest page range: 1-1"); + const auto expected = query(acl, "get guests"); + for (const char* command : {"get guests ", "get guests\r\n", + "get guests\t01 \r\n", "get guests 0000000000001"}) { + CHECK(query(acl, command) == expected); + } +} + +static void matching_has_token_boundaries() { + ClientACL acl; + char reply[157] = "unchanged"; + for (const char* command : {"", "get", "get guest", "get guestsx", + "get guests.other", "set guests"}) { + CHECK(!mesh::cli::handleGuestsGet(acl, command, reply, sizeof(reply))); + CHECK(strcmp(reply, "unchanged") == 0); + } + CHECK(!mesh::cli::handleGuestsGet(acl, nullptr, reply, sizeof(reply))); + CHECK(mesh::cli::handleGuestsGet(acl, "get guests", nullptr, sizeof(reply))); +} + +static void bounds_never_truncate_keys() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + add(acl, 0, PERM_ACL_GUEST)->last_activity = UINT32_MAX; + const auto expected = query(acl, "get guests"); + for (size_t capacity = 1; capacity <= 160; ++capacity) { + char reply[161]; + memset(reply, '#', sizeof(reply)); + CHECK(mesh::cli::handleGuestsGet(acl, "get guests", reply, capacity)); + CHECK(reply[capacity] == '#'); + CHECK(strlen(reply) < capacity); + if (capacity > expected.size()) CHECK(reply == expected); + else CHECK(strstr(reply, "Guests 1/1") == nullptr); + } + char byte = '#'; + CHECK(mesh::cli::handleGuestsGet(acl, "get guests", &byte, 0)); + CHECK(byte == '#'); +} + +static void revocation_removes_guest_and_listing_is_read_only() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + const auto* removed = add(acl, 0, PERM_ACL_GUEST); + uint8_t key[PUB_KEY_SIZE]; memcpy(key, removed->id.pub_key, sizeof(key)); + const auto* kept = add(acl, 1, PERM_ACL_GUEST); + const auto expected = "Guests 1/1 (1)\n" + row(kept); + add(acl, 2, PERM_ACL_ADMIN); + CHECK(acl.applyPermissions(self, key, PUB_KEY_SIZE, 0)); + CHECK(acl.getClient(key, PUB_KEY_SIZE) == nullptr); + CHECK(query(acl, "get guests") == expected); + std::vector before; + for (int i = 0; i < acl.getNumClients(); ++i) before.push_back(*acl.getClientByIdx(i)); + const auto writes = fs.bytes_written; + const auto files = fs.files; + for (const char* command : {"get guests", "get guests 1", "get guests 2", "get guests 0"}) { + query(acl, command); + } + CHECK(acl.getNumClients() == int(before.size())); + for (int i = 0; i < acl.getNumClients(); ++i) { + CHECK(memcmp(acl.getClientByIdx(i), &before[i], sizeof(ClientInfo)) == 0); + } + CHECK(fs.bytes_written == writes && fs.files == files); +} + +static void disabling_removes_every_guest_and_preserves_other_roles() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + fs.files["/login-replay-test"] = {1, 2, 3, 4}; + const auto files = fs.files; + const auto writes = fs.bytes_written; + std::vector kept; + for (unsigned i = 0; i < MAX_CLIENTS; ++i) { + const uint8_t role = uint8_t(i % 8); + auto* client = add(acl, i, uint8_t(role | 0xA0)); + client->out_path[0] = uint8_t(i); + client->out_path_len = 1; + client->permissions_are_explicit = true; + if (role != PERM_ACL_GUEST) kept.push_back(*client); + else { + // Invalid/inactive rows and RTC=0 sessions must all be removed too. + if (i % 16 == 0) client->last_activity = 0; + if (i % 32 == 0) memset(client->shared_secret, 0, PUB_KEY_SIZE); + } + } + const int old_count = acl.getNumClients(); + CHECK(acl.removeGuestClients() == old_count - int(kept.size())); + CHECK(acl.getNumClients() == int(kept.size())); + CHECK(query(acl, "get guests") == "Guests: empty"); + for (int i = 0; i < acl.getNumClients(); ++i) { + CHECK(memcmp(acl.getClientByIdx(i), &kept[i], sizeof(ClientInfo)) == 0); + } + const ClientInfo empty = {}; + for (int i = acl.getNumClients(); i < old_count; ++i) { + CHECK(memcmp(acl.getClientByIdx(i), &empty, sizeof(ClientInfo)) == 0); + } + CHECK(acl.removeGuestClients() == 0); + CHECK(fs.files == files && fs.bytes_written == writes); + + ClientACL all; + all.load(&fs, self); + for (unsigned i = 0; i < MAX_CLIENTS; ++i) add(all, i, PERM_ACL_GUEST); + CHECK(all.removeGuestClients() == MAX_CLIENTS && all.getNumClients() == 0); + CHECK(all.removeGuestClients() == 0); + CHECK(add(all, MAX_CLIENTS, PERM_ACL_ADMIN) != nullptr); + CHECK(all.getNumClients() == 1); + ClientACL empty_acl; + CHECK(empty_acl.removeGuestClients() == 0); +} + +static void actual_dispatch_keeps_guest_controls_admin_only() { + FakeFilesystem fs; mesh::LocalIdentity self; + ClientACL acl; + acl.load(&fs, self); + auto* sender = add(acl, 0, PERM_ACL_ADMIN); + add(acl, 1, PERM_ACL_GUEST); + for (auto handler : guest_handlers) { + for (unsigned permissions = 0; permissions <= 255; ++permissions) { + sender->permissions = uint8_t(permissions); + for (const char* input : {"get guests", "get guests 1", "xy|GET guests", + "xy|get guests 1", "get guest", "set guest on", + "xy|set guest off"}) { + char command[40], reply[161]; + strcpy(command, input); + memset(reply, '#', sizeof(reply)); + setting_calls = 0; + handler(acl, sender, 0, command, reply); + CHECK(reply[160] == '#'); + CHECK(strlen(reply) < 160); + const char* body = input[2] == '|' ? reply + 3 : reply; + if ((permissions & PERM_ACL_ROLE_MASK) != PERM_ACL_ADMIN) { + CHECK(strcmp(body, "Err - not permitted") == 0); + CHECK(setting_calls == 0); + } else { + CHECK(strstr(body, "not permitted") == nullptr); + if (strstr(input, "guests")) { + CHECK(setting_calls == 0); + CHECK(std::string(body) == query(acl, "get guests")); + } else { + CHECK(setting_calls == 1); + CHECK(strcmp(body, "guest setting handled") == 0); + } + } + } + } + // Physical/local command dispatch has no peer role and remains available. + char command[] = "get guests", reply[160] = {}; + handler(acl, nullptr, 0, command, reply); + CHECK(std::string(reply) == query(acl, "get guests")); + } +} + +int main() { + empty_and_guest_only(); + skips_inactive_and_other_roles(); + full_table_pages_preserve_full_keys(); + rejects_bad_pages(); + matching_has_token_boundaries(); + bounds_never_truncate_keys(); + revocation_removes_guest_and_listing_is_read_only(); + disabling_removes_every_guest_and_preserves_other_roles(); + actual_dispatch_keeps_guest_controls_admin_only(); + puts("9 guest CLI checks passed"); +} diff --git a/test/fixtures/guest_login/mocks/Adafruit_LittleFS.h b/test/fixtures/guest_login/mocks/Adafruit_LittleFS.h new file mode 100644 index 000000000..584a738b2 --- /dev/null +++ b/test/fixtures/guest_login/mocks/Adafruit_LittleFS.h @@ -0,0 +1,4 @@ +#pragma once +#include +using Adafruit_LittleFS = GuestMemoryFS; +namespace Adafruit_LittleFS_Namespace {} diff --git a/test/fixtures/guest_login/mocks/FS.h b/test/fixtures/guest_login/mocks/FS.h new file mode 100644 index 000000000..2971ef422 --- /dev/null +++ b/test/fixtures/guest_login/mocks/FS.h @@ -0,0 +1,3 @@ +#pragma once +#include +namespace fs { using FS = GuestMemoryFS; } diff --git a/test/fixtures/guest_login/mocks/GuestMemoryFS.h b/test/fixtures/guest_login/mocks/GuestMemoryFS.h new file mode 100644 index 000000000..9af1d2bae --- /dev/null +++ b/test/fixtures/guest_login/mocks/GuestMemoryFS.h @@ -0,0 +1,109 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class GuestMemoryFS; +inline GuestMemoryFS* guest_metadata_fs = nullptr; +using GuestFiles = std::map>; +enum { FILE_O_READ = 1, FILE_O_WRITE = 2 }; +class File { + GuestMemoryFS* owner_ = nullptr; + std::string path_; + size_t cursor_ = 0; + bool opened_ = false; +public: + File() = default; + explicit File(GuestMemoryFS& fs) : owner_(&fs) {} + File(GuestMemoryFS* fs, const char* path, bool opened) : owner_(fs), path_(path), opened_(opened) {} + bool open(const char*, int); + explicit operator bool() const { return opened_; } + bool isDirectory() const { return false; } + size_t size() const; + size_t read(uint8_t*, size_t); + size_t write(const uint8_t*, size_t); + bool setBufferSize(size_t) { return true; } + void flush(); + void close(); +}; + +class GuestMemoryFS { +public: + GuestFiles files; + std::vector snapshots; + bool capture = false, fail_metadata = false, fail_open_write = false; + bool fail_stage_open = false, fail_stage_read = false, corrupt_stage = false; + bool fail_public_read = false, published = false; + bool fail_remove_temp = false, fail_remove_backup = false; + bool fail_backup = false, fail_publish = false, false_publish = false, late_error = false; + int short_write = -1; + unsigned writes = 0; + GuestMemoryFS() { guest_metadata_fs = this; } + bool mkdir(const char*) { return true; } + void record() { if (capture) snapshots.push_back(files); } + void _lockFS() {} void _unlockFS() {} GuestMemoryFS* _getFS() { return this; } + bool exists(const char* path) { const bool yes = files.count(path); record(); return yes; } + bool remove(const char* path) { + const bool fail = (fail_remove_temp && !strcmp(path, "/guest_login.tmp")) + || (fail_remove_backup && !strcmp(path, "/guest_login.bak")); + const bool removed = !fail && files.erase(path); record(); return removed; + } + bool rename(const char* from, const char* to) { + const bool is_publish = !strcmp(from, "/guest_login.tmp"); + if ((fail_backup && !strcmp(from, "/guest_login")) || (fail_publish && is_publish) + || !files.count(from)) { record(); return false; } +#if defined(ESP32_PLATFORM) + if (files.count(to)) { record(); return false; } +#endif + if (!(false_publish && is_publish)) { + files[to] = files.at(from); files.erase(from); + if (is_publish) published = true; + } + record(); + return !(late_error && is_publish); + } + File open(const char* path, const char* mode = "r", bool = false) { + if (!path) return {}; + if (*mode == 'w') { + if (fail_open_write) { record(); return {}; } + files[path].clear(); + } else if (fail_stage_open && !strcmp(path, "/guest_login.tmp")) { + record(); return {}; + } + const bool found = files.count(path); record(); return File(this, path, found); + } +}; +inline bool File::open(const char* path, int mode) { + if (!owner_) return false; + *this = owner_->open(path, mode == FILE_O_WRITE ? "w" : "r"); + return opened_; +} +inline size_t File::size() const { return opened_ ? owner_->files.at(path_).size() : 0; } +inline size_t File::read(uint8_t* bytes, size_t count) { + if (!opened_ || (owner_->fail_stage_read && path_ == "/guest_login.tmp") + || (owner_->fail_public_read && owner_->published && path_ == "/guest_login")) { + if (owner_) owner_->record(); return 0; + } + const auto& image = owner_->files.at(path_); + count = std::min(count, image.size() - cursor_); + memcpy(bytes, image.data() + cursor_, count); cursor_ += count; owner_->record(); return count; +} +inline size_t File::write(const uint8_t* bytes, size_t count) { + if (!opened_) return 0; + ++owner_->writes; + if (owner_->short_write >= 0) count = std::min(count, size_t(owner_->short_write)); + auto& image = owner_->files[path_]; image.insert(image.end(), bytes, bytes + count); + if (owner_->corrupt_stage && !image.empty()) image[0] ^= 1; + owner_->record(); return count; +} +inline void File::flush() { if (owner_) owner_->record(); } +inline void File::close() { opened_ = false; if (owner_) owner_->record(); } +constexpr int LFS_ERR_NOENT = -2; +struct lfs_info {}; +inline int lfs_stat(GuestMemoryFS* fs, const char* path, lfs_info*) { + if (fs->fail_metadata) return -5; + const bool found = fs->files.count(path); fs->record(); return found ? 0 : LFS_ERR_NOENT; +} diff --git a/test/fixtures/guest_login/mocks/Identity.h b/test/fixtures/guest_login/mocks/Identity.h new file mode 100644 index 000000000..9e5ab089b --- /dev/null +++ b/test/fixtures/guest_login/mocks/Identity.h @@ -0,0 +1,2 @@ +#pragma once +namespace mesh { class LocalIdentity; } diff --git a/test/fixtures/guest_login/mocks/sys/stat.h b/test/fixtures/guest_login/mocks/sys/stat.h new file mode 100644 index 000000000..db5d55998 --- /dev/null +++ b/test/fixtures/guest_login/mocks/sys/stat.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include +struct stat {}; +inline int stat(const char* path, struct stat*) { + if (!guest_metadata_fs || strncmp(path, "/spiffs", 7) != 0) { errno = EIO; return -1; } + if (guest_metadata_fs->fail_metadata) { errno = EIO; return -1; } + const bool found = guest_metadata_fs->files.count(path + 7) != 0; + guest_metadata_fs->record(); + if (!found) { errno = ENOENT; return -1; } + return 0; +} diff --git a/test/fixtures/guest_login/test.cpp b/test/fixtures/guest_login/test.cpp new file mode 100644 index 000000000..ab7a50740 --- /dev/null +++ b/test/fixtures/guest_login/test.cpp @@ -0,0 +1,147 @@ +#include +#include +#include +#include + +using mesh::GuestLoginSettings; +using Bytes = std::vector; +constexpr char Path[] = "/guest_login"; +Bytes image(bool enabled) { + Bytes bytes = {'G', 'L', 'G', '1', uint8_t(enabled), 0, 0, 0, 0, 0, 0, 0}; + mesh::storage::writeLE32(bytes.data() + 8, + mesh::storage::updateCRC32(0xffffffff, bytes.data(), 8)); + return bytes; +} +void basics() { + GuestLoginSettings no_fs; + assert(!no_fs.enabled() && !no_fs.healthy() && !no_fs.setEnabled(true)); + assert(!no_fs.begin(nullptr)); + GuestMemoryFS fs; + GuestLoginSettings settings; + assert(settings.begin(&fs) && settings.healthy() && !settings.enabled()); + assert(fs.files.empty() && fs.writes == 0); // Older installs remain off without migration writes. + assert(settings.setEnabled(true) && settings.enabled()); + assert(fs.files.at(Path) == image(true)); + GuestLoginSettings reboot; + assert(reboot.begin(&fs) && reboot.enabled()); + assert(reboot.setEnabled(false) && !reboot.enabled()); + assert(fs.files.at(Path) == image(false)); + assert(settings.begin(&fs) && !settings.enabled()); + assert(!fs.files.count("/guest_login.tmp") && !fs.files.count("/guest_login.bak")); +} +void malformed() { + for (unsigned length = 0; length < 25; ++length) { + if (length == 12) continue; + GuestMemoryFS fs; + fs.files[Path] = image(true); fs.files[Path].resize(length); + GuestLoginSettings settings; + assert(!settings.begin(&fs) && !settings.enabled()); + assert(settings.healthy() && settings.setEnabled(true)); // Explicit admin repairs malformed image. + } + for (unsigned byte = 0; byte < 12; ++byte) { + GuestMemoryFS fs; fs.files[Path] = image(true); fs.files[Path][byte] ^= 1; + GuestLoginSettings settings; + assert(!settings.begin(&fs) && !settings.enabled()); + assert(settings.setEnabled(false)); + } + GuestMemoryFS fs; fs.files[Path] = image(true); fs.files[Path][4] = 2; + mesh::storage::writeLE32(fs.files[Path].data() + 8, + mesh::storage::updateCRC32(0xffffffff, fs.files[Path].data(), 8)); + GuestLoginSettings settings; + assert(!settings.begin(&fs) && !settings.enabled()); +} +void failedSaves() { + for (unsigned fault = 0; fault < 10; ++fault) { + GuestMemoryFS fs; fs.files[Path] = image(true); + GuestLoginSettings settings; assert(settings.begin(&fs)); + switch (fault) { + case 0: fs.fail_open_write = true; break; + case 1: fs.short_write = 5; break; + case 2: fs.corrupt_stage = true; break; + case 3: fs.fail_stage_open = true; break; + case 4: fs.fail_stage_read = true; break; + case 5: fs.fail_publish = true; break; + case 6: fs.false_publish = true; break; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + case 7: fs.fail_backup = true; break; + case 8: fs.files["/guest_login.bak"] = image(false); fs.fail_remove_backup = true; break; +#else + case 7: fs.fail_open_write = true; break; + case 8: fs.short_write = 0; break; +#endif + case 9: fs.files["/guest_login.tmp"] = image(false); fs.fail_remove_temp = true; break; + } + const bool saved = settings.setEnabled(false); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + // AtomicFileWriter can truncate scratch even when stale scratch removal fails. + if (fault == 9) { assert(saved && !settings.enabled()); continue; } +#endif + assert(!saved); + assert(fs.files.at(Path) == image(true)); + if (settings.healthy()) assert(settings.enabled()); + else assert(!settings.enabled() && !settings.setEnabled(true)); + GuestMemoryFS reboot_fs; reboot_fs.files = fs.files; + GuestLoginSettings reboot; assert(reboot.begin(&reboot_fs) && reboot.enabled()); + } + for (unsigned count = 0; count < 12; ++count) { + GuestMemoryFS fs; fs.files[Path] = image(false); + GuestLoginSettings settings; assert(settings.begin(&fs)); fs.short_write = count; + assert(!settings.setEnabled(true) && !settings.enabled()); + assert(fs.files.at(Path) == image(false)); + } +} +void uncertainPublication() { + for (bool old : {false, true}) { + GuestMemoryFS fs; fs.files[Path] = image(old); + GuestLoginSettings settings; assert(settings.begin(&fs)); fs.fail_public_read = true; + assert(!settings.setEnabled(!old)); + assert(!settings.healthy() && !settings.enabled() && !settings.setEnabled(old)); + fs.fail_public_read = false; + assert(settings.begin(&fs) && settings.enabled() == !old); + } + GuestMemoryFS fs; fs.files[Path] = image(false); + GuestLoginSettings settings; assert(settings.begin(&fs)); fs.late_error = true; + assert(settings.setEnabled(true) && settings.enabled()); // Exact public bytes beat rename return value. +} +void rebootEveryBoundary() { + for (bool old : {false, true}) { + GuestMemoryFS fs; fs.files[Path] = image(old); + GuestLoginSettings settings; assert(settings.begin(&fs)); fs.capture = true; + assert(settings.setEnabled(!old)); + const auto snapshots = fs.snapshots; + assert(snapshots.size() > 10); + for (const auto& snapshot : snapshots) { + GuestMemoryFS reboot_fs; reboot_fs.files = snapshot; + GuestLoginSettings reboot; + assert(reboot.begin(&reboot_fs)); + if (snapshot.count(Path)) assert(reboot.enabled() == (snapshot.at(Path) == image(true))); + else if (snapshot.count("/guest_login.bak")) + assert(reboot.enabled() == (snapshot.at("/guest_login.bak") == image(true))); + else assert(!reboot.enabled()); + } + } + GuestMemoryFS fs; fs.files["/guest_login.tmp"] = image(true); + GuestLoginSettings settings; + assert(settings.begin(&fs) && !settings.enabled()); // Never recover uncommitted enable scratch. +} +void loadErrors() { + GuestMemoryFS fs; fs.files[Path] = image(true); + GuestLoginSettings settings; fs.fail_public_read = fs.published = true; + assert(!settings.begin(&fs) && !settings.healthy() && !settings.enabled()); + assert(!settings.setEnabled(true)); +#if !defined(RP2040_PLATFORM) + fs.fail_public_read = false; fs.fail_metadata = true; + assert(!settings.begin(&fs) && !settings.healthy() && !settings.enabled()); +#endif +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "basics")) basics(); + else if (!strcmp(argv[1], "malformed")) malformed(); + else if (!strcmp(argv[1], "failed_saves")) failedSaves(); + else if (!strcmp(argv[1], "uncertain")) uncertainPublication(); + else if (!strcmp(argv[1], "reboot")) rebootEveryBoundary(); + else if (!strcmp(argv[1], "load_errors")) loadErrors(); + else assert(false); + printf("guest login %s passed\n", argv[1]); +} diff --git a/test/test_fleet_command/test_fleet_command.cpp b/test/test_fleet_command/test_fleet_command.cpp index d375c1cd2..aa04aaaee 100644 --- a/test/test_fleet_command/test_fleet_command.cpp +++ b/test/test_fleet_command/test_fleet_command.cpp @@ -110,6 +110,7 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) { "get radio2.cross", "set radio2.cross auto", "set radio2.cross on", "set radio2.cross off", "set radio2 off", "set tempradio2 off", "set radio2 mediamesh", "set radio2 mediamesh-range", + "get guest", "set guest on", "set guest off", "get guests", "get guests 2", "set radio2 910.5,500,5,5,rxtx", "set tempradio2 910.5,500,5,5,rx,10,auto", "set radio2 910.5,500,5,5,rx,32", @@ -259,6 +260,8 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) { "get radio2 ", "get radio2 off", "del radio2", "set radio2", "set radio2 mediamesh-extra", "set radio2 mediamesh,32", "set tempradio2 mediamesh", "get radio2 mediamesh", + "set guest auto", "set guest off extra", "del guest", "get guest on", + "set guests on", "get guests 0", "get guests 65536", "get guests 1.secret", "set radio2.cross true", "del flood.channel.data", "set flood.filter.2", "del flood.rule.2 extra", "get flood.rule.1 all", "set flood.max", "set radio2 910.5,500,5,5,rxtx,auto,99", diff --git a/test/test_fleet_command_crypto.py b/test/test_fleet_command_crypto.py index e60a36d77..ea75f9e34 100644 --- a/test/test_fleet_command_crypto.py +++ b/test/test_fleet_command_crypto.py @@ -459,6 +459,17 @@ class FleetCommandCryptoTests(unittest.TestCase): "set tempradio2 mediamesh", "get radio2 mediamesh"]: self.assertEqual(self.decode(envelope(command)), "reject") + def test_guest_controls_and_pages_have_signed_command_boundaries(self): + for command in ["get guest", "set guest on", "set guest off", "get guests", "get guests 2"]: + expected = envelope(command) + self.assertEqual(self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command), + expected.hex()) + self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}") + for command in ["set guest auto", "set guest off extra", "get guest on", "del guest", + "get guests 0", "get guests 01", "get guests 65536", "get guests.2", + "set guests on", "get guests 1.secret", "get guest.password"]: + self.assertEqual(self.decode(envelope(command)), "reject") + def test_scheduled_radio_commands_have_cross_language_real_signatures(self): for command in [ "set radioat 910.5,500,5,5,+1,auto", diff --git a/test/test_guest_cli.py b/test/test_guest_cli.py new file mode 100644 index 000000000..7993a5a89 --- /dev/null +++ b/test/test_guest_cli.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Execute guest pages against the real ACL implementation and hex encoder.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +MOCKS = ROOT / "test/fixtures/client_acl_spiffs/mocks" +FIXTURE = ROOT / "test/fixtures/guest_cli/test_guest_cli.cpp" + + +def guest_dispatch(source): + body = extract_braced(source, "void MyMesh::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] == '|')") + dispatches = list(re.finditer( + r"if \(handleGuestCommand\(command, reply, 157\)\s*" + r"\|\| mesh::cli::handleGuestsGet\(acl, command, reply, 157\)\) return;", body)) + if not dispatches: + raise AssertionError("guest command dispatch is absent") + generated = '#include \n' + for signature in ( + "static bool commandFamilyMatches(", "static bool commandListMatches(", + "static bool isCommonManagerReadOnlyAllowed(", + "static bool isRegionMgrAllowed(", "static bool isFilterMgrAllowed(", + ): + generated += extract_braced(source, signature) + "\n" + names = [] + for index, dispatch in enumerate(dispatches): + guard_start = body.rindex("if (sender && !sender->isAdmin())", 0, dispatch.start()) + guard = extract_braced(body[guard_start:], "if (sender && !sender->isAdmin())") + # No delegated code may run between the guard and guest command branch. + if body[guard_start + len(guard):dispatch.start()].strip(): + raise AssertionError("guest command dispatch must immediately follow its role guard") + name = f"guestCommand{index}" + names.append(name) + generated += f""" +static void {name}(ClientACL& acl, ClientInfo* sender, uint32_t sender_timestamp, + char* command, char* reply) {{ + {zero_guard} + while (*command == ' ') ++command; + {prefix} + mesh::cli::normalizeCommandVerb(command); + {guard} + {dispatch.group()} + strcpy(reply, "unhandled"); +}} +""" + generated += "using GuestHandler = void(*)(ClientACL&, ClientInfo*, uint32_t, char*, char*);\n" + generated += "static const GuestHandler guest_handlers[] = {" + ",".join(names) + "};\n" + return generated + + +class GuestCliTest(unittest.TestCase): + def test_real_acl_and_guest_pages(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + 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}\n" + generated += guest_dispatch((ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()) + with tempfile.TemporaryDirectory(prefix=".tmp-guest-cli-", dir=ROOT) as directory: + work = Path(directory) + (work / "Utils.h").write_text("""#pragma once +#include +#include +namespace mesh { class Utils { public: + static void toHex(char*, const uint8_t*, size_t); +}; } +""") + (work / "production.h").write_text(generated) + for clients in (32, 256): + for repeater_only in (0, 1): + with self.subTest(max_clients=clients, repeater_only=repeater_only): + binary = work / f"guest-cli-{clients}-{repeater_only}.exe" + sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", *sanitizers, + "-DESP32=1", "-DESP32_PLATFORM=1", + "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1", + f"-DMAX_CLIENTS={clients}", f"-DMESH_CLIENT_REPEATER_ONLY={repeater_only}", + f"-I{work}", f"-I{MOCKS}", f"-I{ROOT / 'src'}", + str(FIXTURE), "-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=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("9 guest CLI checks passed", checked.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_guest_login_settings.py b/test/test_guest_login_settings.py new file mode 100644 index 000000000..8047e63db --- /dev/null +++ b/test/test_guest_login_settings.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Exercise saved guest enable state using production filesystem transactions.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/guest_login" + + +class GuestLoginSettingsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("host C++ compiler required") + cls.work = tempfile.TemporaryDirectory(prefix="mesh-guest-settings-") + cls.binaries = {} + sanitizer = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"] + if os.name != "nt" else []) + for backend in ("ESP32", "RP2040", "NRF52", "STM32"): + binary = Path(cls.work.name) / (backend + (".exe" if os.name == "nt" else "")) + sources = [str(FIXTURE / "test.cpp"), str(ROOT / "src/helpers/GuestLoginSettings.cpp")] + if backend in ("NRF52", "STM32"): + sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp")) + result = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-misleading-indentation", "-Wno-sign-compare", *sanitizer, + "-D" + backend + "_PLATFORM", *(["-DESP32"] if backend == "ESP32" else []), + "-I", str(FIXTURE / "mocks"), + "-I", str(ROOT / "src"), *sources, "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + if result.returncode: + cls.work.cleanup() + raise AssertionError(backend + ":\n" + result.stdout + result.stderr) + cls.binaries[backend] = binary + + @classmethod + def tearDownClass(cls): + cls.work.cleanup() + + def scenario(self, name): + for backend, binary in self.binaries.items(): + with self.subTest(backend=backend): + result = subprocess.run([str(binary), name], capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_default_and_saved_toggle(self): + self.scenario("basics") + + def test_malformed_records_fail_closed(self): + self.scenario("malformed") + + def test_failed_saves_preserve_previous_setting(self): + self.scenario("failed_saves") + + def test_uncertain_save_blocks_enable_until_reload(self): + self.scenario("uncertain") + + def test_restart_at_each_filesystem_operation(self): + self.scenario("reboot") + + def test_load_io_failures_keep_guests_disabled(self): + self.scenario("load_errors") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_auth.py b/test/test_morse_auth.py index 2f3803842..e07eddadb 100644 --- a/test/test_morse_auth.py +++ b/test/test_morse_auth.py @@ -23,6 +23,12 @@ struct ClientInfo { bool isFilterMgr() const { return (permissions & FilterManager) != 0; } }; +// Guest authorization has its own real-handler suite. These unrelated +// dispatch targets must not consume any of the Morse commands below. +namespace mesh { namespace cli { +bool handleGuestsGet(int, const char*, char*, size_t) { return false; } +} } + @MANAGER_HELPERS@ struct CommonCLI { @@ -39,6 +45,8 @@ struct CommonCLI { struct LegacyPortable { CommonCLI _cli; + int acl = 0; + bool handleGuestCommand(const char*, char*, size_t) { return false; } void dispatch(ClientInfo* sender, uint32_t sender_timestamp, const char* command, char* reply) { @PORTABLE_HANDOFF@ diff --git a/test/test_repeater_guest_access.py b/test/test_repeater_guest_access.py new file mode 100644 index 000000000..f8c5498a1 --- /dev/null +++ b/test/test_repeater_guest_access.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Execute repeater guest authorization, saved toggles, and receive admission.""" +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/guest_login" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +#define MAX_PATH_SIZE 64 +#define MAX_CLIENTS 16 +#define MESH_CLIENT_REPEATER_ONLY 1 +#define MESH_ENABLE_FLOOD_RULE_ENGINE 1 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +@CONSTANTS@ +namespace mesh { +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE]; + bool matches(const Identity& other) const { + return memcmp(pub_key, other.pub_key, sizeof(pub_key)) == 0; + } + bool isHashMatch(const uint8_t* hash) const { return pub_key[0] == hash[0]; } +}; +struct Packet { + bool direct = true; + bool isRouteDirect() const { return direct; } +}; +} +@CLIENT_INFO@; +struct ClientACL { + ClientInfo clients[MAX_CLIENTS]{}; + int num_clients = 0; + unsigned authorizations = 0, allocations = 0; + bool authorize_ok = true, allocate_ok = true; + uint8_t authorized_role = 255; + ClientInfo* getClient(const uint8_t* key, int length) { + for (int i = 0; i < num_clients; ++i) + if (memcmp(clients[i].id.pub_key, key, size_t(length)) == 0) return &clients[i]; + return nullptr; + } + int getNumClients() const { return num_clients; } + ClientInfo* getClientByIdx(int i) { return &clients[i]; } + const ClientInfo* getClientByIdx(int i) const { return &clients[i]; } + ClientInfo* putClient(const mesh::Identity& id, uint8_t permissions) { + ++allocations; + if (auto* old = getClient(id.pub_key, PUB_KEY_SIZE)) return old; + if (!allocate_ok || num_clients == MAX_CLIENTS) return nullptr; + auto* client = &clients[num_clients++]; + *client = ClientInfo{}; + client->id = id; + client->permissions = permissions; + client->out_path_len = OUT_PATH_UNKNOWN; + client->alt_path_len = OUT_PATH_UNKNOWN; + client->observed_path_len = OUT_PATH_UNKNOWN; + client->out_path_is_persistable = true; + return client; + } + bool authorizeLoginTimestamp(const uint8_t*, uint32_t timestamp, + uint32_t floor, uint8_t role) { + ++authorizations; + authorized_role = role; + return authorize_ok && mesh::planClientLoginReplay(floor, 0, timestamp).fresh; + } + int removeGuestClients(); +}; +@REMOVE_GUESTS@ +struct Clock { + uint32_t now = 1234567890; + uint32_t getCurrentTime() const { return now; } + uint32_t getCurrentTimeUnique() { return now + ++unique; } + uint32_t unique = 0; +}; +struct RNG { void random(uint8_t* data, size_t len) { memset(data, 0xB5, len); } }; +@MANAGER_HELPERS@ +struct MyMesh { + GuestMemoryFS fs; + mesh::GuestLoginSettings guest_login; + ClientACL acl; + Clock clock; + RNG rng; + struct Prefs { char password[16] = "admin"; char guest_password[16] = ""; } _prefs; + unsigned long dirty_contacts_expiry = 0; + uint8_t contacts_save_failures = 0, reply_data[160]{}; + int matching_peer_indexes[MAX_CLIENTS]{}; + unsigned received = 0; + MyMesh() { assert(guest_login.begin(&fs)); } + Clock* getRTCClock() { return &clock; } + RNG* getRNG() { return &rng; } + unsigned long futureMillis(unsigned long delay) { return 10000 + delay; } + uint8_t handleLoginReq(const mesh::Identity&, const uint8_t*, uint32_t, const uint8_t*, bool); + bool handleGuestCommand(const char*, char*, size_t); + int searchPeersByHash(const uint8_t*); + void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t); + bool onPeerPathRecv(mesh::Packet*, int, const uint8_t*, uint8_t*, uint8_t, uint8_t, uint8_t*, uint8_t); + int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, size_t); + void dispatch(ClientInfo*, const char*, char*); +}; +@LOGIN@ +@COMMAND@ +@SEARCH@ +@PATH@ +@DATA_ADMISSION@ +@REQUEST_ADMISSION@ +@DISPATCH@ + +static mesh::Identity identity(unsigned number) { + mesh::Identity id; + memset(id.pub_key, 0xAA, PUB_KEY_SIZE); + id.pub_key[PUB_KEY_SIZE - 1] = uint8_t(number); + return id; +} +static const uint8_t secret[PUB_KEY_SIZE] = {0xB7}; +static uint8_t login(MyMesh& mesh, unsigned id, const char* password, + uint32_t timestamp = 100, bool flood = true) { + guest_metadata_fs = &mesh.fs; + return mesh.handleLoginReq(identity(id), secret, timestamp, + reinterpret_cast(password), flood); +} +static ClientInfo* add(MyMesh& mesh, unsigned number, uint8_t role) { + auto* client = mesh.acl.putClient(identity(number), role); + assert(client); + memcpy(client->shared_secret, secret, sizeof(secret)); + client->last_activity = 7; + client->last_timestamp = 10; + client->out_path_len = 1; + client->out_path[0] = 0x42; + return client; +} +static void command(MyMesh& mesh, const char* text, const char* expected) { + guest_metadata_fs = &mesh.fs; + char reply[160]; + memset(reply, '#', sizeof(reply)); + assert(mesh.handleGuestCommand(text, reply, 157)); + assert(strcmp(reply, expected) == 0); + assert(reply[157] == '#' && reply[158] == '#' && reply[159] == '#'); +} +static void defaultsAndPasswords() { + MyMesh mesh; + assert(!mesh.guest_login.enabled() && mesh.fs.files.empty()); + assert(login(mesh, 1, "") == 0 && mesh.acl.authorizations == 0); + assert(login(mesh, 1, "wrong") == 0 && mesh.acl.num_clients == 0); + command(mesh, "get guest", "> off"); + command(mesh, "set guest on", "OK - guest on"); + assert(login(mesh, 1, "") == 13); + assert(mesh.reply_data[6] == 0 && mesh.reply_data[7] == PERM_ACL_GUEST); + assert(mesh.acl.clients[0].last_activity == mesh.clock.now); + assert(mesh.dirty_contacts_expiry == 0); // Guest sessions stay out of the ACL file. + strcpy(mesh._prefs.guest_password, "guest-code"); + const ClientInfo before = mesh.acl.clients[0]; + assert(login(mesh, 1, "", 101) == 0); // Cached identity cannot bypass changed password. + assert(login(mesh, 1, "wrong", 102) == 0); + assert(memcmp(&before, &mesh.acl.clients[0], sizeof(before)) == 0); + assert(login(mesh, 1, "guest-code", 103) == 13); + assert(login(mesh, 1, "guest-code", 103) == 0); // Existing replay floor still applies. + MyMesh reboot; + reboot.fs.files = mesh.fs.files; + guest_metadata_fs = &reboot.fs; + assert(reboot.guest_login.begin(&reboot.fs) && reboot.guest_login.enabled()); + assert(reboot.acl.num_clients == 0); + command(reboot, "set guest off", "OK - guest off"); + MyMesh off_reboot; + off_reboot.fs.files = reboot.fs.files; + guest_metadata_fs = &off_reboot.fs; + assert(off_reboot.guest_login.begin(&off_reboot.fs) && !off_reboot.guest_login.enabled()); + assert(login(off_reboot, 2, "") == 0); + MyMesh blank_admin; + blank_admin._prefs.password[0] = 0; + assert(login(blank_admin, 1, "") == 0); + command(blank_admin, "set guest on", "OK - guest on"); + assert(login(blank_admin, 1, "") == 13 && blank_admin.reply_data[7] == PERM_ACL_GUEST); +} +static void assignedRoles() { + for (unsigned permissions = 0; permissions < 256; ++permissions) { + if ((permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) continue; + MyMesh mesh; + auto* client = add(mesh, 1, uint8_t(permissions)); + assert(login(mesh, 1, "wrong", 100, false) == 13); + assert(client->permissions == permissions && client->out_path_len == 1); + assert(client->last_timestamp == 100 && client->last_activity == mesh.clock.now); + assert(mesh.reply_data[7] == permissions); + assert(login(mesh, 1, "admin", 101) == 13); + assert(client->permissions == ((permissions & ~PERM_ACL_ROLE_MASK) | PERM_ACL_ADMIN)); + } + MyMesh guest; + add(guest, 1, 0xA0); + const ClientInfo before = guest.acl.clients[0]; + assert(login(guest, 1, "", 100) == 0 && guest.acl.authorizations == 0); + assert(memcmp(&before, &guest.acl.clients[0], sizeof(before)) == 0); + assert(login(guest, 1, "admin", 101) == 13); + assert(guest.acl.clients[0].permissions == (0xA0 | PERM_ACL_ADMIN)); + MyMesh failure; + failure.acl.authorize_ok = false; + assert(login(failure, 1, "admin") == 0 && failure.acl.allocations == 0); + failure.acl.authorize_ok = true; + failure.acl.allocate_ok = false; + assert(login(failure, 1, "admin") == 0 && failure.acl.num_clients == 0); +} +static void revocationAndFailures() { + MyMesh mesh; + command(mesh, "set guest on", "OK - guest on"); + add(mesh, 1, PERM_ACL_GUEST); + const ClientInfo admin = *add(mesh, 2, PERM_ACL_ADMIN); + add(mesh, 3, 0xF8); + const ClientInfo manager = *add(mesh, 4, PERM_ACL_FILTER_MGR); + command(mesh, "set guest off", "OK - guest off"); + assert(mesh.acl.num_clients == 2); + assert(memcmp(&admin, &mesh.acl.clients[0], sizeof(admin)) == 0); + assert(memcmp(&manager, &mesh.acl.clients[1], sizeof(manager)) == 0); + ClientInfo zero{}; + assert(memcmp(&zero, &mesh.acl.clients[2], sizeof(zero)) == 0); + assert(memcmp(&zero, &mesh.acl.clients[3], sizeof(zero)) == 0); + command(mesh, "set guest on", "OK - guest on"); + assert(mesh.acl.getClient(identity(1).pub_key, PUB_KEY_SIZE) == nullptr); + assert(login(mesh, 1, "", 200) == 13); + mesh.fs.fail_open_write = true; + command(mesh, "set guest off", "Err - guest setting save unconfirmed; reboot may differ"); + assert(mesh.guest_login.healthy() && mesh.guest_login.enabled()); + assert(mesh.acl.num_clients == 3); // Confirmed old setting remains live. + mesh.fs.fail_open_write = false; + mesh.fs.fail_public_read = true; + command(mesh, "set guest off", "Err - guest setting save unconfirmed; reboot may differ"); + assert(!mesh.guest_login.healthy() && !mesh.guest_login.enabled()); + assert(mesh.acl.num_clients == 2); // Uncertain storage fails closed and revokes. + assert(login(mesh, 1, "", 201) == 0); + command(mesh, "get guest", "> off (settings unavailable)"); + assert(login(mesh, 2, "wrong", 300) == 13); // Assigned admin survives fail-closed guest storage. +} +static void commandPermissions() { + MyMesh mesh; + for (const char* invalid : {"get guest on", "set guest", "set guest 1", "set guest ON", + "set guest on extra", "set guest off extra"}) { + command(mesh, invalid, "Err - use get guest or set guest on|off"); + assert(!mesh.guest_login.enabled() && mesh.fs.writes == 0); + } + for (const char* unrelated : {"get guests", "get guest.password", "set guest.password secret", + "get guestx", "set guestx on"}) { + char reply[160] = "untouched"; + assert(!mesh.handleGuestCommand(unrelated, reply, sizeof(reply))); + assert(strcmp(reply, "untouched") == 0); + } + for (uint8_t role : {PERM_ACL_GUEST, PERM_ACL_READ_ONLY, PERM_ACL_READ_WRITE, + PERM_ACL_REGION_MGR, PERM_ACL_FILTER_MGR, 6, 7}) { + ClientInfo caller{}; + caller.permissions = role; + char reply[160]{}; + mesh.dispatch(&caller, "set guest on", reply); + assert(strcmp(reply, "Err - not permitted") == 0); + assert(!mesh.guest_login.enabled()); + mesh.dispatch(&caller, "get guest", reply); + assert(strcmp(reply, "Err - not permitted") == 0); + } + ClientInfo admin{}; + admin.permissions = PERM_ACL_ADMIN; + char reply[160]{}; + mesh.dispatch(&admin, "set guest on", reply); + assert(strcmp(reply, "OK - guest on") == 0 && mesh.guest_login.enabled()); + mesh.dispatch(nullptr, "set guest off", reply); + assert(strcmp(reply, "OK - guest off") == 0 && !mesh.guest_login.enabled()); +} +static void receiveAdmission() { + MyMesh mesh; + // Deliberately leave stale/in-flight guest state to exercise receive gates. + auto* guest = add(mesh, 1, 0xA0); + auto* admin = add(mesh, 2, PERM_ACL_ADMIN); + const uint8_t hash[] = {0xAA}; + assert(mesh.searchPeersByHash(hash) == 1 && mesh.matching_peer_indexes[0] == 1); + mesh::Packet packet; + uint8_t data[8] = {1}, path[] = {0x55, 0x66}; + mesh.matching_peer_indexes[0] = 0; + const ClientInfo before = *guest; + mesh.onPeerDataRecv(&packet, 0, 0, secret, data, sizeof(data)); + assert(mesh.received == 0); + assert(mesh.handleRequest(guest, 100, data, sizeof(data), 160) == 0); + assert(!mesh.onPeerPathRecv(&packet, 0, secret, path, 2, 0x0F, nullptr, 0)); + assert(memcmp(&before, guest, sizeof(before)) == 0); + mesh.matching_peer_indexes[0] = 1; + mesh.onPeerDataRecv(&packet, 0, 0, secret, data, sizeof(data)); + assert(mesh.received == 1); + assert(mesh.handleRequest(admin, 100, data, sizeof(data), 160) == 1); + assert(!mesh.onPeerPathRecv(&packet, 0, secret, path, 2, 0x0F, nullptr, 0)); + assert(admin->out_path_len == 2 && admin->last_activity == mesh.clock.now); + command(mesh, "set guest on", "OK - guest on"); + assert(mesh.searchPeersByHash(hash) == 2); + mesh.matching_peer_indexes[0] = 0; + mesh.onPeerDataRecv(&packet, 0, 0, secret, data, sizeof(data)); + assert(mesh.received == 3); + assert(mesh.handleRequest(guest, 100, data, sizeof(data), 160) == 1); + assert(!mesh.onPeerPathRecv(&packet, 0, secret, path, 2, 0x0F, nullptr, 0)); + assert(guest->out_path_len == 2 && guest->last_activity == mesh.clock.now); + // A flood login still clears the old selected path and transient observation. + guest->observed_path_pending = true; + assert(login(mesh, 1, "", 200, true) == 13); + assert(guest->out_path_len == OUT_PATH_UNKNOWN && !guest->observed_path_pending); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "passwords")) defaultsAndPasswords(); + else if (!strcmp(argv[1], "roles")) assignedRoles(); + else if (!strcmp(argv[1], "revoke")) revocationAndFailures(); + else if (!strcmp(argv[1], "commands")) commandPermissions(); + else if (!strcmp(argv[1], "receive")) receiveAdmission(); + else assert(false); + printf("repeater guest %s passed\n", argv[1]); +} +''' + + +class RepeaterGuestAccessTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("host C++17 compiler required") + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + acl_header = (ROOT / "src/helpers/ClientACL.h").read_text() + acl_source = (ROOT / "src/helpers/ClientACL.cpp").read_text() + constants = "\n".join(line for line in acl_header.splitlines() + if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) + for name in ("FIRMWARE_VER_LEVEL", "RESP_SERVER_LOGIN_OK", "LAZY_CONTACTS_WRITE_DELAY"): + constants += "\n" + re.search(r"^#define\s+" + name + r"\s+.*$", source, re.MULTILINE).group() + manager_helpers = "\n".join(extract_braced(source, signature) for signature in ( + "static bool commandFamilyMatches(", "static bool commandListMatches(", + "static bool isCommonManagerReadOnlyAllowed(", "static bool isRegionMgrAllowed(", + "static bool isFilterMgrAllowed(", + )) + dispatch = extract_braced(source, "void MyMesh::handleCommand(uint32_t sender_timestamp,") + call = dispatch.index("if (handleGuestCommand(command, reply, 157)") + guard_start = dispatch.rindex("if (sender && !sender->isAdmin())", 0, call) + guard = extract_braced(dispatch[guard_start:], "if (sender && !sender->isAdmin())") + # Exercise the actual authorization branch and setter, excluding unrelated + # radio commands and the independently tested guest-list formatter. + command_dispatch = "void MyMesh::dispatch(ClientInfo* sender, const char* command, char* reply) {\n" + guard + "\nif (handleGuestCommand(command, reply, 157)) return;\nstrcpy(reply, \"unhandled\");\n}" + peer = extract_braced(source, "void MyMesh::onPeerDataRecv(") + gate_end = peer.index("#if defined(WITH_MQTT_NEIGHBORS)", peer.index("ClientInfo* client = acl.getClientByIdx(i);")) + # Compile the real receive admission through the guest gate. Radio request + # execution is replaced by a counter only after authorization succeeds. + data_admission = peer[:gate_end] + "++received;\n}" + request = extract_braced(source, "int MyMesh::handleRequest(") + request_admission = request[:request.index(" if (reply_capacity >")] + "++received; return 1;\n}" + cls.source_text = source + replacements = { + "@CONSTANTS@": constants, + "@CLIENT_INFO@": extract_braced(acl_header, "struct ClientInfo {"), + "@REMOVE_GUESTS@": extract_braced(acl_source, "int ClientACL::removeGuestClients("), + "@MANAGER_HELPERS@": manager_helpers, + "@LOGIN@": extract_braced(source, "uint8_t MyMesh::handleLoginReq("), + "@COMMAND@": extract_braced(source, "bool MyMesh::handleGuestCommand("), + "@SEARCH@": extract_braced(source, "int MyMesh::searchPeersByHash("), + "@PATH@": extract_braced(source, "bool MyMesh::onPeerPathRecv("), + "@DATA_ADMISSION@": data_admission, + "@REQUEST_ADMISSION@": request_admission, + "@DISPATCH@": command_dispatch, + } + generated = HARNESS + for marker, text in replacements.items(): + generated = generated.replace(marker, text) + cls.work = tempfile.TemporaryDirectory(prefix="mesh-repeater-guest-") + cls.addClassCleanup(cls.work.cleanup) + work = Path(cls.work.name) + cpp = work / "test.cpp" + cpp.write_text(generated) + cls.binary = work / ("guest.exe" if sys.platform == "win32" else "guest") + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + if sys.platform.startswith("linux") else []) + result = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-misleading-indentation", + "-DESP32_PLATFORM", "-DESP32", *sanitizer, + "-I", str(FIXTURE / "mocks"), "-I", str(ROOT / "src"), + str(cpp), str(ROOT / "src/helpers/GuestLoginSettings.cpp"), "-o", str(cls.binary), + ], capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + def scenario(self, name): + result = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("repeater guest " + name + " passed", result.stdout) + + def test_default_password_change_and_reboot(self): + self.scenario("passwords") + + def test_assigned_roles_and_admin_upgrade(self): + self.scenario("roles") + + def test_disable_revokes_only_guests_and_storage_failure_fails_closed(self): + self.scenario("revoke") + + def test_command_validation_and_admin_authorization(self): + self.scenario("commands") + + def test_peer_request_and_path_admission(self): + self.scenario("receive") + + def test_guest_controls_precede_portable_role_handoff(self): + # Current FULL builds and legacy PORTABLE_MQTT_OBSERVER builds both need + # the same saved policy and permissions, rather than a generic CLI fallback. + body = extract_braced(self.source_text, "void MyMesh::handleCommand(uint32_t sender_timestamp,") + self.assertEqual(body.count("handleGuestCommand(command, reply, 157)"), 2) + self.assertEqual(body.count("mesh::cli::handleGuestsGet(acl, command, reply, 157)"), 2) + + +if __name__ == "__main__": + unittest.main()