Default repeater guest login off and expose guest sessions
This commit is contained in:
1 parent
a587a391a5
commit
c433b40e51
26 files changed
+1529
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include <GuestMemoryFS.h>
|
||||
using Adafruit_LittleFS = GuestMemoryFS;
|
||||
namespace Adafruit_LittleFS_Namespace {}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
#include <GuestMemoryFS.h>
|
||||
namespace fs { using FS = GuestMemoryFS; }
|
||||
+109
@@ -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
@@ -0,0 +1,2 @@
|
||||
#pragma once
|
||||
namespace mesh { class LocalIdentity; }
|
||||
+12
@@ -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;
|
||||
}
|
||||
Vendored
+147
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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@
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user