Default repeater guest login off and expose guest sessions

This commit is contained in:
mikecarper committed 2026-10-21 11:11:07 -07:00
1 parent a587a391a5
commit c433b40e51
26 files changed
+1529 -5

No files matched your search

+3
View File
@@ -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
+61
View File
@@ -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:** `<blank>`
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 <page>` - 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 <page>/<pages> (<total>)` and shows one guest's full
public key followed by `last=<UTC epoch seconds>`. 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
+6
View File
@@ -303,6 +303,8 @@ Reboot or changing enrollment discards the partial transfers.
| UTC clock | `clock`, `clock sync`, `time <epoch-seconds>` |
| Profile diagnostics | `get radio2.status`, `get radio2.timing` |
| Crossover | `get radio2.cross`, `set radio2.cross <auto|on|off>` |
| 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
+14
View File
@@ -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.
+47 -4
View File
@@ -38,6 +38,7 @@
#include <helpers/NRF52VoltagePolicy.h>
#endif
#include <helpers/ClientACLCLI.h>
#include <helpers/GuestCLI.h>
#include <helpers/ClientACLResponse.h>
#include <helpers/ClockSyncUtils.h>
#include <helpers/ClientLoginPersistence.h>
@@ -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
+3
View File
@@ -7,6 +7,7 @@
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/FloodGroupPlainText.h>
#include <helpers/FloodRuleExtensions.h>
#include <helpers/GuestLoginSettings.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#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)
+16
View File
@@ -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++) {
+1
View File
@@ -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
+11 -1
View File
@@ -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) {
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include "ClientACL.h"
#include <Utils.h>
#include <stdio.h>
#include <string.h>
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<unsigned long>(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<unsigned long>(client->last_activity));
}
return true;
}
return true;
}
} // namespace cli
} // namespace mesh
+83
View File
@@ -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<FILESYSTEM>)) 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<FILESYSTEM>);
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
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <helpers/IdentityStore.h>
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
@@ -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;
+282
View File
@@ -0,0 +1,282 @@
#include <helpers/ClientACL.cpp>
#include <helpers/GuestCLI.h>
#include <helpers/CLICommandUtils.h>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
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<unsigned long>(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<ClientInfo> 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<ClientInfo> 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");
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <GuestMemoryFS.h>
using Adafruit_LittleFS = GuestMemoryFS;
namespace Adafruit_LittleFS_Namespace {}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#include <GuestMemoryFS.h>
namespace fs { using FS = GuestMemoryFS; }
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <map>
#include <string>
#include <vector>
class GuestMemoryFS;
inline GuestMemoryFS* guest_metadata_fs = nullptr;
using GuestFiles = std::map<std::string, std::vector<uint8_t>>;
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<GuestFiles> 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;
}
+2
View File
@@ -0,0 +1,2 @@
#pragma once
namespace mesh { class LocalIdentity; }
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <GuestMemoryFS.h>
#include <cerrno>
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;
}
+147
View File
@@ -0,0 +1,147 @@
#include <helpers/GuestLoginSettings.h>
#include <helpers/PersistentStoreFormat.h>
#include <cassert>
#include <cstdio>
using mesh::GuestLoginSettings;
using Bytes = std::vector<uint8_t>;
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]);
}
@@ -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",
+11
View File
@@ -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",
+100
View File
@@ -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 <helpers/FloodCommandAliases.h>\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 <stddef.h>
#include <stdint.h>
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()
+71
View File
@@ -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()
+8
View File
@@ -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@
+423
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <helpers/GuestLoginSettings.h>
#include <helpers/ClientLoginPersistence.h>
#include <helpers/ClientPathObservation.h>
#include <helpers/ClientPathPersistence.h>
#include <helpers/LazyPersistence.h>
#include <helpers/FloodCommandAliases.h>
#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<const uint8_t*>(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()