Add recent repeater login history with short key prefixes
This commit is contained in:
1 parent
c433b40e51
commit
f68d4ca544
16 files changed
+657
-12
No files matched your search
@@ -674,6 +674,7 @@ jobs:
|
||||
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_login_history.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
|
||||
|
||||
@@ -4340,6 +4340,44 @@ clears them.
|
||||
|
||||
---
|
||||
|
||||
#### View repeater login attempts
|
||||
|
||||
**Usage:**
|
||||
|
||||
- `get logins` - first page
|
||||
- `get logins <page>` - a numbered page, starting at 1
|
||||
|
||||
Available on repeaters through local administrative consoles, an authenticated
|
||||
remote admin session, or enrolled [signed fleet commands](fleet_management.md).
|
||||
Guests and delegated region/filter managers cannot read this list.
|
||||
|
||||
The repeater keeps the 16 most recently attempted public keys in RAM, with the
|
||||
newest attempt first. Each row shows only the first **12 hexadecimal characters**
|
||||
of the key, the latest attempt's UTC epoch time, its result, and an attempt count.
|
||||
There are up to two rows per page. Attempts for the same full key update its
|
||||
existing row and count; the oldest key is replaced when the list is full.
|
||||
The count stops at 4,294,967,295. Two different keys can share
|
||||
the displayed prefix; their records and counts remain separate.
|
||||
|
||||
```text
|
||||
Logins 1/1 (2)
|
||||
ABCDEF123456 n=3 last=1792600000 accepted
|
||||
123456ABCDEF n=1 last=1792599900 guests-disabled
|
||||
```
|
||||
|
||||
Results are `accepted`, `wrong-password`, `guests-disabled`, `replay/state`, and
|
||||
`acl-unavailable`. An accepted login can be an admin, an assigned ACL client,
|
||||
or a guest. This history records
|
||||
actual login requests after packet authentication; anonymous owner, region, and
|
||||
clock requests, incomplete requests, and packets that fail authentication do not
|
||||
appear. Passwords and shared secrets are never recorded.
|
||||
|
||||
This is recent attempt history, not a list of online users or a complete audit
|
||||
log. Pages can change as attempts arrive. A reboot clears it; `set guest off`
|
||||
does not. A zero timestamp means the clock was not set when the attempt arrived.
|
||||
|
||||
---
|
||||
|
||||
#### Recover a repeater's future-dated replay timestamp
|
||||
|
||||
This is an explicit recovery operation after correcting a bad clock, not a
|
||||
|
||||
@@ -305,6 +305,7 @@ Reboot or changing enrollment discards the partial transfers.
|
||||
| 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]` |
|
||||
| Repeater login attempts | `get logins [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]` |
|
||||
@@ -320,6 +321,16 @@ 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.
|
||||
|
||||
`get logins [page]` returns recent repeater login attempts, two per page, showing
|
||||
only 12-character public-key prefixes, latest UTC epoch times, results, and
|
||||
attempt counts. The 16 most recently attempted full keys are kept in RAM;
|
||||
repeated attempts update the existing key's record. Results distinguish accepted
|
||||
logins, wrong passwords, disabled guests, replay/state rejection, and unavailable
|
||||
ACL capacity. The history clears on reboot and survives `set guest off`.
|
||||
It contains no passwords or shared secrets and is not an online-user list.
|
||||
Anonymous owner, region, and clock requests and packets that fail authentication
|
||||
are excluded. Ordinary channel messages cannot read this list.
|
||||
|
||||
`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
|
||||
|
||||
@@ -109,6 +109,7 @@ the corresponding feature:
|
||||
| `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 |
|
||||
| `get logins [page]` | Recent repeater login attempts, with 12-character public-key prefixes |
|
||||
|
||||
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
|
||||
@@ -649,6 +650,20 @@ 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`.
|
||||
|
||||
Use `cmd get logins` or `cmd get logins 2` from an admin session to inspect recent
|
||||
login attempts. The repeater retains up to 16 distinct public keys in RAM, newest
|
||||
attempt first, with up to two rows per page. Each row shows a 12-character hex
|
||||
key prefix, the latest UTC epoch time, result, and attempt count. Repeated
|
||||
attempts update the same full key's row; different keys sharing a displayed
|
||||
prefix remain separate records.
|
||||
|
||||
Results distinguish accepted logins, wrong passwords, disabled guests,
|
||||
replay/state rejection, and unavailable ACL capacity. Only authenticated,
|
||||
complete login requests are recorded; anonymous owner, region, or clock requests
|
||||
are excluded. Passwords and shared secrets are never stored. This bounded
|
||||
history clears on reboot, survives `set guest off`, and does not show who is
|
||||
currently online. A zero timestamp means the node's clock was unset.
|
||||
|
||||
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.
|
||||
|
||||
@@ -571,6 +571,8 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn
|
||||
}
|
||||
|
||||
uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) {
|
||||
using LoginResult = mesh::LoginHistory::Result;
|
||||
const uint32_t attempt_time = getRTCClock()->getCurrentTime();
|
||||
ClientInfo* existing_client =
|
||||
acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
const bool admin_password =
|
||||
@@ -598,8 +600,10 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr
|
||||
perms = PERM_ACL_GUEST;
|
||||
} else {
|
||||
#if MESH_DEBUG
|
||||
MESH_DEBUG_PRINTLN("Invalid password: %s", data);
|
||||
MESH_DEBUG_PRINTLN("Login rejected: credentials or guest policy");
|
||||
#endif
|
||||
login_history.record(sender.pub_key, attempt_time, guest_login.enabled()
|
||||
? LoginResult::WrongPassword : LoginResult::GuestsDisabled);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -611,6 +615,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr
|
||||
sender.pub_key, sender_timestamp, previous_timestamp, perms)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Login rejected: replayed timestamp or replay state unavailable");
|
||||
login_history.record(sender.pub_key, attempt_time, LoginResult::ReplayOrStateRejected);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -618,11 +623,13 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr
|
||||
client = acl.putClient(sender, 0); // add to contacts (if not already known)
|
||||
if (client == NULL) {
|
||||
MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts");
|
||||
login_history.record(sender.pub_key, attempt_time, LoginResult::ACLUnavailable);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
login_history.record(sender.pub_key, attempt_time, LoginResult::Accepted);
|
||||
const uint8_t previous_permissions = client->permissions;
|
||||
const bool reset_out_path = is_flood
|
||||
&& client->out_path_len != OUT_PATH_FORCE_FLOOD;
|
||||
@@ -12577,7 +12584,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
}
|
||||
if (handleGuestCommand(command, reply, 157)
|
||||
|| mesh::cli::handleGuestsGet(acl, command, reply, 157)) return;
|
||||
|| mesh::cli::handleGuestsGet(acl, command, reply, 157)
|
||||
|| mesh::cli::handleLoginsGet(login_history, 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;
|
||||
@@ -12599,7 +12607,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
}
|
||||
|
||||
if (handleGuestCommand(command, reply, 157)
|
||||
|| mesh::cli::handleGuestsGet(acl, command, reply, 157)) return;
|
||||
|| mesh::cli::handleGuestsGet(acl, command, reply, 157)
|
||||
|| mesh::cli::handleLoginsGet(login_history, command, reply, 157)) return;
|
||||
|
||||
if (strncmp(command, "backup prv.key", 14) == 0
|
||||
&& (command[14] == 0 || command[14] == ' ')) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <helpers/FloodGroupPlainText.h>
|
||||
#include <helpers/FloodRuleExtensions.h>
|
||||
#include <helpers/GuestLoginSettings.h>
|
||||
#include <helpers/LoginHistory.h>
|
||||
#if defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#endif
|
||||
@@ -383,6 +384,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
#endif
|
||||
NodePrefs _prefs;
|
||||
mesh::GuestLoginSettings guest_login;
|
||||
mesh::LoginHistory login_history;
|
||||
bool handleGuestCommand(const char* command, char* reply, size_t capacity);
|
||||
ClientACL acl;
|
||||
CommonCLI _cli;
|
||||
|
||||
@@ -268,6 +268,7 @@ const CommandFamily* commandFamily(const char* key, size_t length) {
|
||||
{"radio2.cross", CommandKind::Cross},
|
||||
{"guest", CommandKind::Guest},
|
||||
{"guests", CommandKind::Guests},
|
||||
{"logins", CommandKind::Guests},
|
||||
{"flood.channel.data", CommandKind::Setting},
|
||||
{"flood.channel.data.hops", CommandKind::Setting},
|
||||
{"flood.max", CommandKind::Setting},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Recent radio login decisions. RAM only: attempts never write flash or retain
|
||||
// passwords. Keep the complete key internally even though the CLI shows a prefix.
|
||||
class LoginHistory {
|
||||
public:
|
||||
static constexpr size_t KEY_SIZE = 32;
|
||||
static constexpr size_t CAPACITY = 16;
|
||||
|
||||
enum class Result : uint8_t {
|
||||
Accepted,
|
||||
WrongPassword,
|
||||
GuestsDisabled,
|
||||
ReplayOrStateRejected,
|
||||
ACLUnavailable,
|
||||
};
|
||||
|
||||
struct Entry {
|
||||
uint8_t key[KEY_SIZE];
|
||||
uint32_t timestamp;
|
||||
uint32_t attempts;
|
||||
Result result;
|
||||
};
|
||||
|
||||
void record(const uint8_t* key, uint32_t timestamp, Result result) {
|
||||
if (key == nullptr) return;
|
||||
size_t index = 0;
|
||||
while (index < _size && memcmp(_entries[index].key, key, KEY_SIZE) != 0) ++index;
|
||||
|
||||
Entry latest = {};
|
||||
if (index < _size) {
|
||||
latest = _entries[index];
|
||||
if (latest.attempts != UINT32_MAX) ++latest.attempts;
|
||||
} else {
|
||||
memcpy(latest.key, key, KEY_SIZE);
|
||||
latest.attempts = 1;
|
||||
if (_size < CAPACITY) ++_size;
|
||||
index = _size - 1;
|
||||
}
|
||||
latest.timestamp = timestamp;
|
||||
latest.result = result;
|
||||
if (index != 0) memmove(_entries + 1, _entries, index * sizeof(Entry));
|
||||
_entries[0] = latest;
|
||||
}
|
||||
|
||||
size_t size() const { return _size; }
|
||||
const Entry* get(size_t index) const { return index < _size ? &_entries[index] : nullptr; }
|
||||
|
||||
private:
|
||||
Entry _entries[CAPACITY] = {};
|
||||
uint8_t _size = 0;
|
||||
};
|
||||
|
||||
namespace cli {
|
||||
|
||||
inline const char* loginResultName(LoginHistory::Result result) {
|
||||
switch (result) {
|
||||
case LoginHistory::Result::Accepted: return "accepted";
|
||||
case LoginHistory::Result::WrongPassword: return "wrong-password";
|
||||
case LoginHistory::Result::GuestsDisabled: return "guests-disabled";
|
||||
case LoginHistory::Result::ReplayOrStateRejected: return "replay/state";
|
||||
case LoginHistory::Result::ACLUnavailable: return "acl-unavailable";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Call after the repeater's admin authorization check. Two rows fit in the
|
||||
// 157-byte radio reply even with maximum counters and UTC timestamps.
|
||||
inline bool handleLoginsGet(const LoginHistory& history, const char* command,
|
||||
char* reply, size_t capacity) {
|
||||
static const char prefix[] = "get logins";
|
||||
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 logins [page]");
|
||||
return true;
|
||||
}
|
||||
|
||||
const unsigned total = unsigned(history.size());
|
||||
if (total == 0 && page == 1) {
|
||||
snprintf(reply, capacity, "Logins: empty");
|
||||
return true;
|
||||
}
|
||||
const unsigned pages = total == 0 ? 1 : (total + 1) / 2;
|
||||
if (page > pages) {
|
||||
snprintf(reply, capacity, "Err - login page range: 1-%u", pages);
|
||||
return true;
|
||||
}
|
||||
|
||||
char rows[2][80] = {};
|
||||
unsigned count = 0;
|
||||
for (unsigned index = (page - 1) * 2; index < total && count < 2; ++index) {
|
||||
const auto& entry = *history.get(index);
|
||||
char key[13];
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
for (size_t i = 0; i < 6; ++i) {
|
||||
key[i * 2] = hex[entry.key[i] >> 4];
|
||||
key[i * 2 + 1] = hex[entry.key[i] & 0x0F];
|
||||
}
|
||||
key[12] = 0;
|
||||
snprintf(rows[count], sizeof(rows[count]), "%s n=%lu last=%lu %s", key,
|
||||
static_cast<unsigned long>(entry.attempts),
|
||||
static_cast<unsigned long>(entry.timestamp), loginResultName(entry.result));
|
||||
++count;
|
||||
}
|
||||
const char* separator = count == 2 ? "\n" : "";
|
||||
const int needed = snprintf(nullptr, 0, "Logins %u/%u (%u)\n%s%s%s",
|
||||
page, pages, total, rows[0], separator, rows[1]);
|
||||
// Never accidentally expose a partly formatted public-key prefix.
|
||||
if (needed < 0 || size_t(needed) >= capacity) {
|
||||
snprintf(reply, capacity, "Err - login reply too small");
|
||||
} else {
|
||||
snprintf(reply, capacity, "Logins %u/%u (%u)\n%s%s%s",
|
||||
page, pages, total, rows[0], separator, rows[1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace mesh
|
||||
+29
-4
@@ -1,5 +1,6 @@
|
||||
#include <helpers/ClientACL.cpp>
|
||||
#include <helpers/GuestCLI.h>
|
||||
#include <helpers/LoginHistory.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -7,6 +8,7 @@
|
||||
#include <vector>
|
||||
|
||||
static unsigned setting_calls = 0;
|
||||
static mesh::LoginHistory login_history;
|
||||
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;
|
||||
@@ -51,6 +53,15 @@ static std::string query(const ClientACL& acl, const char* command) {
|
||||
return reply;
|
||||
}
|
||||
|
||||
static std::string login_query(const char* command) {
|
||||
char reply[160];
|
||||
memset(reply, '#', sizeof(reply));
|
||||
CHECK(mesh::cli::handleLoginsGet(login_history, 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;
|
||||
@@ -232,12 +243,18 @@ static void actual_dispatch_keeps_guest_controls_admin_only() {
|
||||
acl.load(&fs, self);
|
||||
auto* sender = add(acl, 0, PERM_ACL_ADMIN);
|
||||
add(acl, 1, PERM_ACL_GUEST);
|
||||
uint8_t key[PUB_KEY_SIZE];
|
||||
memset(key, 0xAB, sizeof(key));
|
||||
login_history.record(key, 1234, mesh::LoginHistory::Result::GuestsDisabled);
|
||||
CHECK(login_query("get logins") ==
|
||||
"Logins 1/1 (1)\nABABABABABAB n=1 last=1234 guests-disabled");
|
||||
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"}) {
|
||||
"xy|set guest off", "get logins", "get logins 1",
|
||||
"xy|GET logins", "xy|get logins 1"}) {
|
||||
char command[40], reply[161];
|
||||
strcpy(command, input);
|
||||
memset(reply, '#', sizeof(reply));
|
||||
@@ -254,6 +271,10 @@ static void actual_dispatch_keeps_guest_controls_admin_only() {
|
||||
if (strstr(input, "guests")) {
|
||||
CHECK(setting_calls == 0);
|
||||
CHECK(std::string(body) == query(acl, "get guests"));
|
||||
} else if (strstr(input, "logins")) {
|
||||
CHECK(setting_calls == 0);
|
||||
CHECK(std::string(body) == login_query("get logins"));
|
||||
CHECK(strstr(body, "ABABABABABABAB") == nullptr);
|
||||
} else {
|
||||
CHECK(setting_calls == 1);
|
||||
CHECK(strcmp(body, "guest setting handled") == 0);
|
||||
@@ -262,9 +283,13 @@ static void actual_dispatch_keeps_guest_controls_admin_only() {
|
||||
}
|
||||
}
|
||||
// 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"));
|
||||
for (const char* input : {"get guests", "get logins"}) {
|
||||
char command[16], reply[160] = {};
|
||||
strcpy(command, input);
|
||||
handler(acl, nullptr, 0, command, reply);
|
||||
CHECK(std::string(reply) == (strstr(input, "guests")
|
||||
? query(acl, "get guests") : login_query("get logins")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#include <helpers/LoginHistory.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
#include <string>
|
||||
|
||||
static bool forbid_allocations = false;
|
||||
|
||||
void* operator new(size_t size) {
|
||||
assert(!forbid_allocations);
|
||||
if (void* memory = std::malloc(size)) return memory;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
void* operator new[](size_t size) { return ::operator new(size); }
|
||||
void operator delete(void* memory) noexcept { std::free(memory); }
|
||||
void operator delete[](void* memory) noexcept { std::free(memory); }
|
||||
|
||||
using mesh::LoginHistory;
|
||||
using Result = LoginHistory::Result;
|
||||
static_assert(LoginHistory::KEY_SIZE == 32, "deduplicate complete public keys");
|
||||
static_assert(sizeof(LoginHistory) <= 720, "bounded embedded RAM");
|
||||
|
||||
static void make_key(uint8_t* key, unsigned number) {
|
||||
for (size_t i = 0; i < LoginHistory::KEY_SIZE; ++i) key[i] = uint8_t(i * 11 + 0xAB);
|
||||
key[30] = uint8_t(number >> 8);
|
||||
key[31] = uint8_t(number);
|
||||
}
|
||||
|
||||
static void record(LoginHistory& history, unsigned number, uint32_t timestamp,
|
||||
Result result = Result::Accepted) {
|
||||
uint8_t key[LoginHistory::KEY_SIZE];
|
||||
make_key(key, number);
|
||||
history.record(key, timestamp, result);
|
||||
}
|
||||
|
||||
static std::string query(const LoginHistory& history, const char* command = "get logins") {
|
||||
char reply[160];
|
||||
memset(reply, '#', sizeof(reply));
|
||||
assert(mesh::cli::handleLoginsGet(history, command, reply, 157));
|
||||
assert(reply[157] == '#' && reply[158] == '#' && reply[159] == '#');
|
||||
assert(strlen(reply) < 157);
|
||||
return reply;
|
||||
}
|
||||
|
||||
static void empty_history() {
|
||||
LoginHistory history;
|
||||
assert(history.size() == 0 && history.get(0) == nullptr);
|
||||
assert(history.get(size_t(-1)) == nullptr);
|
||||
assert(query(history) == "Logins: empty");
|
||||
assert(query(history, "get logins 1") == "Logins: empty");
|
||||
assert(query(history, "get logins 2") == "Err - login page range: 1-1");
|
||||
history.record(nullptr, 1, Result::Accepted);
|
||||
assert(history.size() == 0);
|
||||
}
|
||||
|
||||
static void newest_first_and_aggregate() {
|
||||
LoginHistory history;
|
||||
record(history, 1, 123, Result::WrongPassword);
|
||||
record(history, 2, 124, Result::GuestsDisabled);
|
||||
record(history, 1, 125);
|
||||
assert(history.size() == 2);
|
||||
assert(history.get(0)->key[31] == 1 && history.get(1)->key[31] == 2);
|
||||
assert(history.get(0)->attempts == 2 && history.get(1)->attempts == 1);
|
||||
assert(history.get(0)->timestamp == 125 && history.get(0)->result == Result::Accepted);
|
||||
assert(query(history) == "Logins 1/1 (2)\nABB6C1CCD7E2 n=2 last=125 accepted\n"
|
||||
"ABB6C1CCD7E2 n=1 last=124 guests-disabled");
|
||||
}
|
||||
|
||||
static void complete_key_deduplication() {
|
||||
LoginHistory history;
|
||||
for (unsigned i = 0; i < LoginHistory::CAPACITY; ++i) record(history, i, i);
|
||||
assert(history.size() == LoginHistory::CAPACITY);
|
||||
for (unsigned i = 0; i < LoginHistory::CAPACITY; ++i) {
|
||||
assert(history.get(i)->key[31] == LoginHistory::CAPACITY - 1 - i);
|
||||
assert(history.get(i)->attempts == 1);
|
||||
}
|
||||
uint8_t key[32];
|
||||
make_key(key, 15);
|
||||
key[6] ^= 1; // Same displayed prefix and suffix, but another complete key.
|
||||
history.record(key, 100, Result::ACLUnavailable);
|
||||
assert(history.size() == LoginHistory::CAPACITY);
|
||||
assert(history.get(0)->attempts == 1 && history.get(1)->attempts == 1);
|
||||
assert(memcmp(history.get(0)->key, history.get(1)->key, 6) == 0);
|
||||
assert(memcmp(history.get(0)->key, history.get(1)->key, 32) != 0);
|
||||
}
|
||||
|
||||
static void eviction_and_recency_ignore_clock() {
|
||||
LoginHistory history;
|
||||
for (unsigned i = 0; i < LoginHistory::CAPACITY; ++i) record(history, i, 100 + i);
|
||||
record(history, 0, 0, Result::ReplayOrStateRejected);
|
||||
assert(history.get(0)->key[31] == 0 && history.get(0)->timestamp == 0);
|
||||
assert(history.get(0)->attempts == 2);
|
||||
record(history, 16, 1);
|
||||
assert(history.get(0)->key[31] == 16 && history.get(1)->key[31] == 0);
|
||||
for (size_t i = 0; i < history.size(); ++i) assert(history.get(i)->key[31] != 1);
|
||||
record(history, 1, 2);
|
||||
assert(history.get(0)->key[31] == 1 && history.get(0)->attempts == 1);
|
||||
assert(history.get(1)->key[31] == 16);
|
||||
}
|
||||
|
||||
static void saturates_and_updates_latest() {
|
||||
LoginHistory history;
|
||||
record(history, 42, UINT32_MAX);
|
||||
auto* entry = const_cast<LoginHistory::Entry*>(history.get(0));
|
||||
entry->attempts = UINT32_MAX - 1;
|
||||
record(history, 42, 0, Result::WrongPassword);
|
||||
assert(history.get(0)->attempts == UINT32_MAX);
|
||||
record(history, 42, 1, Result::ACLUnavailable);
|
||||
assert(history.get(0)->attempts == UINT32_MAX);
|
||||
assert(history.get(0)->timestamp == 1 && history.get(0)->result == Result::ACLUnavailable);
|
||||
const uint8_t* internal_key = history.get(0)->key;
|
||||
history.record(internal_key, 2, Result::GuestsDisabled);
|
||||
assert(history.size() == 1 && history.get(0)->timestamp == 2);
|
||||
}
|
||||
|
||||
static void pages_and_results() {
|
||||
LoginHistory history;
|
||||
const Result results[] = {Result::Accepted, Result::WrongPassword, Result::GuestsDisabled,
|
||||
Result::ReplayOrStateRejected, Result::ACLUnavailable};
|
||||
for (unsigned i = 0; i < 16; ++i) record(history, i, 100 + i, results[i % 5]);
|
||||
for (unsigned page = 1; page <= 8; ++page) {
|
||||
char command[32], expected[32];
|
||||
snprintf(command, sizeof(command), "get logins %u", page);
|
||||
snprintf(expected, sizeof(expected), "Logins %u/8 (16)\n", page);
|
||||
const auto actual = query(history, command);
|
||||
assert(actual.compare(0, strlen(expected), expected) == 0);
|
||||
const size_t newline = actual.find('\n');
|
||||
const size_t second = actual.find('\n', newline + 1);
|
||||
assert(second != std::string::npos && actual.find('\n', second + 1) == std::string::npos);
|
||||
assert(actual[newline + 13] == ' ' && actual[second + 13] == ' ');
|
||||
const unsigned index = (page - 1) * 2;
|
||||
assert(actual.find(mesh::cli::loginResultName(history.get(index)->result)) != std::string::npos);
|
||||
assert(actual.find(mesh::cli::loginResultName(history.get(index + 1)->result)) != std::string::npos);
|
||||
}
|
||||
assert(query(history, "get logins 9") == "Err - login page range: 1-8");
|
||||
LoginHistory odd;
|
||||
for (unsigned i = 0; i < 3; ++i) record(odd, i, 10 + i);
|
||||
assert(query(odd, "get logins 2") == "Logins 2/2 (3)\nABB6C1CCD7E2 n=1 last=10 accepted");
|
||||
assert(strcmp(mesh::cli::loginResultName(static_cast<Result>(255)), "unknown") == 0);
|
||||
}
|
||||
|
||||
static void strict_page_and_command_parsing() {
|
||||
LoginHistory history;
|
||||
record(history, 0, 0);
|
||||
const char* invalid[] = {
|
||||
"get logins 0", "get logins -1", "get logins +1", "get logins 1x",
|
||||
"get logins 1 2", "get logins 1.0", "get logins page 1", "get logins 65536",
|
||||
"get logins 4294967296", "get logins 9999999999999999999999999999",
|
||||
"get logins 1\nset guest on",
|
||||
};
|
||||
for (const char* command : invalid) {
|
||||
assert(query(history, command) == "Err - usage: get logins [page]");
|
||||
}
|
||||
const char* whitespace[] = {"get logins ", "get logins\r\n", "get logins\t01 \r\n",
|
||||
"get logins 0000000000001"};
|
||||
for (const char* command : whitespace) assert(query(history, command) == query(history));
|
||||
assert(query(history, "get logins 65535") == "Err - login page range: 1-1");
|
||||
char reply[157] = "unchanged";
|
||||
for (const char* command : {"", "get", "get login", "get loginsx", "get logins.other",
|
||||
"set logins", "get logins/1"}) {
|
||||
assert(!mesh::cli::handleLoginsGet(history, command, reply, sizeof(reply)));
|
||||
assert(strcmp(reply, "unchanged") == 0);
|
||||
}
|
||||
assert(!mesh::cli::handleLoginsGet(history, nullptr, reply, sizeof(reply)));
|
||||
assert(mesh::cli::handleLoginsGet(history, "get logins", nullptr, sizeof(reply)));
|
||||
}
|
||||
|
||||
static void bounded_replies_never_truncate_prefixes() {
|
||||
LoginHistory history;
|
||||
for (unsigned i = 0; i < 16; ++i) record(history, i, UINT32_MAX, Result::GuestsDisabled);
|
||||
for (size_t i = 0; i < history.size(); ++i) {
|
||||
const_cast<LoginHistory::Entry*>(history.get(i))->attempts = UINT32_MAX;
|
||||
}
|
||||
const auto expected = query(history);
|
||||
assert(expected.size() < 157);
|
||||
assert(expected == "Logins 1/8 (16)\nABB6C1CCD7E2 n=4294967295 last=4294967295 guests-disabled\n"
|
||||
"ABB6C1CCD7E2 n=4294967295 last=4294967295 guests-disabled");
|
||||
for (size_t capacity = 0; capacity <= 180; ++capacity) {
|
||||
char reply[181];
|
||||
memset(reply, '#', sizeof(reply));
|
||||
assert(mesh::cli::handleLoginsGet(history, "get logins", reply, capacity));
|
||||
assert(reply[capacity] == '#');
|
||||
if (capacity == 0) continue;
|
||||
assert(strlen(reply) < capacity);
|
||||
if (capacity > expected.size()) assert(std::string(reply) == expected);
|
||||
else assert(strstr(reply, "Logins") == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static void never_prints_remaining_key_bytes() {
|
||||
LoginHistory history;
|
||||
uint8_t key[32];
|
||||
memset(key, 0xDE, sizeof(key));
|
||||
for (size_t i = 0; i < 6; ++i) key[i] = uint8_t(i);
|
||||
history.record(key, 7, Result::WrongPassword);
|
||||
const auto reply = query(history);
|
||||
assert(reply == "Logins 1/1 (1)\n000102030405 n=1 last=7 wrong-password");
|
||||
assert(reply.find("DEDE") == std::string::npos);
|
||||
}
|
||||
|
||||
static void record_and_format_never_allocate_or_mutate_on_read() {
|
||||
LoginHistory history;
|
||||
uint8_t keys[20][32];
|
||||
for (unsigned i = 0; i < 20; ++i) make_key(keys[i], i);
|
||||
char reply[157];
|
||||
forbid_allocations = true;
|
||||
for (unsigned round = 0; round < 1000; ++round) {
|
||||
history.record(keys[round % 20], round, Result::WrongPassword);
|
||||
assert(mesh::cli::handleLoginsGet(history, "get logins", reply, sizeof(reply)));
|
||||
}
|
||||
LoginHistory snapshot = history;
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
char command[32];
|
||||
snprintf(command, sizeof(command), "get logins %u", i + 1);
|
||||
assert(mesh::cli::handleLoginsGet(history, command, reply, sizeof(reply)));
|
||||
}
|
||||
forbid_allocations = false;
|
||||
assert(history.size() == snapshot.size());
|
||||
for (size_t i = 0; i < history.size(); ++i) {
|
||||
const auto& actual = *history.get(i);
|
||||
const auto& before = *snapshot.get(i);
|
||||
assert(memcmp(actual.key, before.key, 32) == 0);
|
||||
assert(actual.timestamp == before.timestamp && actual.attempts == before.attempts);
|
||||
assert(actual.result == before.result);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
empty_history();
|
||||
newest_first_and_aggregate();
|
||||
complete_key_deduplication();
|
||||
eviction_and_recency_ignore_clock();
|
||||
saturates_and_updates_latest();
|
||||
pages_and_results();
|
||||
strict_page_and_command_parsing();
|
||||
bounded_replies_never_truncate_prefixes();
|
||||
never_prints_remaining_key_bytes();
|
||||
record_and_format_never_allocate_or_mutate_on_read();
|
||||
puts("10 login history checks passed");
|
||||
}
|
||||
@@ -111,6 +111,7 @@ TEST_F(FleetCommandTest, ExplicitRadioAndFilterFamiliesAreAllowed) {
|
||||
"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",
|
||||
"get logins", "get logins 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",
|
||||
@@ -262,6 +263,7 @@ TEST_F(FleetCommandTest, NoGenericAdminNamespaceOrPrefixEscapes) {
|
||||
"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 logins off", "del logins", "get logins 0", "get logins 65536", "get logins.2",
|
||||
"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",
|
||||
|
||||
@@ -460,14 +460,17 @@ class FleetCommandCryptoTests(unittest.TestCase):
|
||||
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"]:
|
||||
for command in ["get guest", "set guest on", "set guest off", "get guests", "get guests 2",
|
||||
"get logins", "get logins 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"]:
|
||||
"set guests on", "get guests 1.secret", "get guest.password",
|
||||
"set logins on", "del logins", "get logins 0", "get logins 01",
|
||||
"get logins 65536", "get logins.2", "get logins 1.secret"]:
|
||||
self.assertEqual(self.decode(envelope(command)), "reject")
|
||||
|
||||
def test_scheduled_radio_commands_have_cross_language_real_signatures(self):
|
||||
|
||||
@@ -21,9 +21,10 @@ def guest_dispatch(source):
|
||||
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))
|
||||
r"\|\| mesh::cli::handleGuestsGet\(acl, command, reply, 157\)\s*"
|
||||
r"\|\| mesh::cli::handleLoginsGet\(login_history, command, reply, 157\)\) return;", body))
|
||||
if not dispatches:
|
||||
raise AssertionError("guest command dispatch is absent")
|
||||
raise AssertionError("guest/login command dispatch is absent")
|
||||
generated = '#include <helpers/FloodCommandAliases.h>\n'
|
||||
for signature in (
|
||||
"static bool commandFamilyMatches(", "static bool commandListMatches(",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the bounded login history and its radio-safe CLI formatter."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/login_history/test_login_history.cpp"
|
||||
|
||||
|
||||
class LoginHistoryTest(unittest.TestCase):
|
||||
def test_history_and_pages(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if compiler is None:
|
||||
self.skipTest("a host C++ compiler is required")
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-login-history-") as directory:
|
||||
binary = Path(directory) / "login-history.exe"
|
||||
sanitizers = ([] if os.name == "nt" else [
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
|
||||
])
|
||||
compiled = subprocess.run([
|
||||
compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", *sanitizers,
|
||||
"-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("10 login history checks passed", checked.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,6 +27,7 @@ struct ClientInfo {
|
||||
// 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; }
|
||||
bool handleLoginsGet(int, const char*, char*, size_t) { return false; }
|
||||
} }
|
||||
|
||||
@MANAGER_HELPERS@
|
||||
@@ -46,6 +47,7 @@ struct CommonCLI {
|
||||
struct LegacyPortable {
|
||||
CommonCLI _cli;
|
||||
int acl = 0;
|
||||
int login_history = 0;
|
||||
bool handleGuestCommand(const char*, char*, size_t) { return false; }
|
||||
void dispatch(ClientInfo* sender, uint32_t sender_timestamp,
|
||||
const char* command, char* reply) {
|
||||
|
||||
@@ -24,8 +24,11 @@ HARNESS = r'''
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/FloodCommandAliases.h>
|
||||
#include <helpers/LoginHistory.h>
|
||||
#define PUB_KEY_SIZE 32
|
||||
#define MAX_PATH_SIZE 64
|
||||
#define MAX_PACKET_PAYLOAD 184
|
||||
#define PAYLOAD_TYPE_ANON_REQ 0x07
|
||||
#define MAX_CLIENTS 16
|
||||
#define MESH_CLIENT_REPEATER_ONLY 1
|
||||
#define MESH_ENABLE_FLOOD_RULE_ENGINE 1
|
||||
@@ -41,7 +44,10 @@ struct Identity {
|
||||
};
|
||||
struct Packet {
|
||||
bool direct = true;
|
||||
uint8_t type = PAYLOAD_TYPE_ANON_REQ;
|
||||
bool isRouteDirect() const { return direct; }
|
||||
bool isRouteFlood() const { return !direct; }
|
||||
uint8_t getPayloadType() const { return type; }
|
||||
};
|
||||
}
|
||||
@CLIENT_INFO@;
|
||||
@@ -93,6 +99,7 @@ struct RNG { void random(uint8_t* data, size_t len) { memset(data, 0xB5, len); }
|
||||
struct MyMesh {
|
||||
GuestMemoryFS fs;
|
||||
mesh::GuestLoginSettings guest_login;
|
||||
mesh::LoginHistory login_history;
|
||||
ClientACL acl;
|
||||
Clock clock;
|
||||
RNG rng;
|
||||
@@ -101,11 +108,17 @@ struct MyMesh {
|
||||
uint8_t contacts_save_failures = 0, reply_data[160]{};
|
||||
int matching_peer_indexes[MAX_CLIENTS]{};
|
||||
unsigned received = 0;
|
||||
unsigned anonymous_requests = 0;
|
||||
uint8_t reply_path_len = 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);
|
||||
void onAnonDataRecv(mesh::Packet*, const uint8_t*, const mesh::Identity&, uint8_t*, size_t);
|
||||
uint8_t handleAnonRegionsReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t) { ++anonymous_requests; return 0; }
|
||||
uint8_t handleAnonOwnerReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t) { ++anonymous_requests; return 0; }
|
||||
uint8_t handleAnonClockReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t) { ++anonymous_requests; return 0; }
|
||||
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);
|
||||
@@ -114,6 +127,7 @@ struct MyMesh {
|
||||
void dispatch(ClientInfo*, const char*, char*);
|
||||
};
|
||||
@LOGIN@
|
||||
@ANONYMOUS@
|
||||
@COMMAND@
|
||||
@SEARCH@
|
||||
@PATH@
|
||||
@@ -128,6 +142,12 @@ static mesh::Identity identity(unsigned number) {
|
||||
return id;
|
||||
}
|
||||
static const uint8_t secret[PUB_KEY_SIZE] = {0xB7};
|
||||
using LoginResult = mesh::LoginHistory::Result;
|
||||
static void history(const MyMesh& mesh, unsigned id, uint32_t attempts, LoginResult result) {
|
||||
const auto* entry = mesh.login_history.get(0);
|
||||
assert(entry && memcmp(entry->key, identity(id).pub_key, PUB_KEY_SIZE) == 0);
|
||||
assert(entry->timestamp == mesh.clock.now && entry->attempts == attempts && entry->result == result);
|
||||
}
|
||||
static uint8_t login(MyMesh& mesh, unsigned id, const char* password,
|
||||
uint32_t timestamp = 100, bool flood = true) {
|
||||
guest_metadata_fs = &mesh.fs;
|
||||
@@ -310,6 +330,84 @@ static void receiveAdmission() {
|
||||
assert(login(mesh, 1, "", 200, true) == 13);
|
||||
assert(guest->out_path_len == OUT_PATH_UNKNOWN && !guest->observed_path_pending);
|
||||
}
|
||||
static void loginHistoryDecisions() {
|
||||
MyMesh mesh;
|
||||
assert(mesh.login_history.size() == 0);
|
||||
assert(login(mesh, 1, "") == 0);
|
||||
assert(mesh.login_history.size() == 1);
|
||||
history(mesh, 1, 1, LoginResult::GuestsDisabled);
|
||||
assert(login(mesh, 1, "wrong") == 0);
|
||||
history(mesh, 1, 2, LoginResult::GuestsDisabled);
|
||||
command(mesh, "set guest on", "OK - guest on");
|
||||
strcpy(mesh._prefs.guest_password, "private-guest");
|
||||
assert(login(mesh, 1, "wrong") == 0);
|
||||
history(mesh, 1, 3, LoginResult::WrongPassword);
|
||||
assert(login(mesh, 1, "private-guest") == 13);
|
||||
history(mesh, 1, 4, LoginResult::Accepted);
|
||||
assert(login(mesh, 1, "private-guest") == 0);
|
||||
history(mesh, 1, 5, LoginResult::ReplayOrStateRejected);
|
||||
mesh.acl.authorize_ok = false;
|
||||
assert(login(mesh, 2, "admin") == 0);
|
||||
history(mesh, 2, 1, LoginResult::ReplayOrStateRejected);
|
||||
mesh.acl.authorize_ok = true;
|
||||
mesh.acl.allocate_ok = false;
|
||||
assert(login(mesh, 2, "admin") == 0);
|
||||
history(mesh, 2, 2, LoginResult::ACLUnavailable);
|
||||
mesh.acl.allocate_ok = true;
|
||||
assert(login(mesh, 2, "admin") == 13);
|
||||
history(mesh, 2, 3, LoginResult::Accepted);
|
||||
command(mesh, "set guest off", "OK - guest off");
|
||||
assert(mesh.login_history.size() == 2); // Revoking sessions preserves attempts.
|
||||
assert(login(mesh, 2, "wrong", 101) == 13); // Authorized ACL identity.
|
||||
history(mesh, 2, 4, LoginResult::Accepted);
|
||||
auto* manager = add(mesh, 3, PERM_ACL_FILTER_MGR);
|
||||
manager->last_timestamp = 10;
|
||||
assert(login(mesh, 3, "wrong", 101) == 13);
|
||||
history(mesh, 3, 1, LoginResult::Accepted);
|
||||
assert(login(mesh, 1, "private-guest", 102) == 0);
|
||||
history(mesh, 1, 6, LoginResult::GuestsDisabled);
|
||||
const auto before = mesh.fs.files;
|
||||
const auto writes = mesh.fs.writes;
|
||||
const uint32_t before_attempts = mesh.login_history.get(0)->attempts;
|
||||
char reply[160];
|
||||
assert(mesh::cli::handleLoginsGet(mesh.login_history, "get logins", reply, sizeof(reply)));
|
||||
assert(!strstr(reply, "private-guest") && !strstr(reply, "admin") && !strstr(reply, "wrong"));
|
||||
assert(mesh.fs.files == before && mesh.fs.writes == writes);
|
||||
assert(mesh.login_history.get(0)->attempts == before_attempts);
|
||||
MyMesh reboot;
|
||||
assert(reboot.login_history.size() == 0); // History is deliberately volatile.
|
||||
}
|
||||
static void anonymousRequestsAreNotLogins() {
|
||||
MyMesh mesh;
|
||||
mesh::Packet packet;
|
||||
uint8_t data[MAX_PACKET_PAYLOAD + 1]{};
|
||||
const auto id = identity(1);
|
||||
guest_metadata_fs = &mesh.fs;
|
||||
for (uint8_t type : {ANON_REQ_TYPE_REGIONS, ANON_REQ_TYPE_OWNER, ANON_REQ_TYPE_BASIC}) {
|
||||
data[4] = type;
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, 6);
|
||||
assert(mesh.login_history.size() == 0);
|
||||
}
|
||||
assert(mesh.anonymous_requests == 3);
|
||||
data[4] = 0x04; // Unknown anonymous query.
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, 6);
|
||||
packet.direct = false;
|
||||
data[4] = ANON_REQ_TYPE_REGIONS;
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, 6);
|
||||
mesh.onAnonDataRecv(nullptr, secret, id, data, 6);
|
||||
mesh.onAnonDataRecv(&packet, secret, id, nullptr, 6);
|
||||
for (size_t length : {size_t(0), size_t(3), size_t(MAX_PACKET_PAYLOAD)})
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, length);
|
||||
packet.type = 0;
|
||||
data[4] = 0;
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, 4);
|
||||
assert(mesh.login_history.size() == 0);
|
||||
packet.type = PAYLOAD_TYPE_ANON_REQ;
|
||||
// A timestamp-only anonymous packet is the valid empty-password login form.
|
||||
mesh.onAnonDataRecv(&packet, secret, id, data, 4);
|
||||
history(mesh, 1, 1, LoginResult::GuestsDisabled);
|
||||
assert(mesh.anonymous_requests == 3);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
if (!strcmp(argv[1], "passwords")) defaultsAndPasswords();
|
||||
@@ -317,6 +415,8 @@ int main(int argc, char** argv) {
|
||||
else if (!strcmp(argv[1], "revoke")) revocationAndFailures();
|
||||
else if (!strcmp(argv[1], "commands")) commandPermissions();
|
||||
else if (!strcmp(argv[1], "receive")) receiveAdmission();
|
||||
else if (!strcmp(argv[1], "history")) loginHistoryDecisions();
|
||||
else if (!strcmp(argv[1], "anonymous")) anonymousRequestsAreNotLogins();
|
||||
else assert(false);
|
||||
printf("repeater guest %s passed\n", argv[1]);
|
||||
}
|
||||
@@ -334,7 +434,8 @@ class RepeaterGuestAccessTests(unittest.TestCase):
|
||||
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"):
|
||||
for name in ("FIRMWARE_VER_LEVEL", "RESP_SERVER_LOGIN_OK", "LAZY_CONTACTS_WRITE_DELAY",
|
||||
"ANON_REQ_TYPE_REGIONS", "ANON_REQ_TYPE_OWNER", "ANON_REQ_TYPE_BASIC"):
|
||||
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(",
|
||||
@@ -355,6 +456,8 @@ class RepeaterGuestAccessTests(unittest.TestCase):
|
||||
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}"
|
||||
anonymous = extract_braced(source, "void MyMesh::onAnonDataRecv(")
|
||||
anonymous = anonymous[:anonymous.index(" if (reply_len == 0) return;")] + " (void)reply_len;\n }\n}"
|
||||
cls.source_text = source
|
||||
replacements = {
|
||||
"@CONSTANTS@": constants,
|
||||
@@ -362,6 +465,7 @@ class RepeaterGuestAccessTests(unittest.TestCase):
|
||||
"@REMOVE_GUESTS@": extract_braced(acl_source, "int ClientACL::removeGuestClients("),
|
||||
"@MANAGER_HELPERS@": manager_helpers,
|
||||
"@LOGIN@": extract_braced(source, "uint8_t MyMesh::handleLoginReq("),
|
||||
"@ANONYMOUS@": anonymous,
|
||||
"@COMMAND@": extract_braced(source, "bool MyMesh::handleGuestCommand("),
|
||||
"@SEARCH@": extract_braced(source, "int MyMesh::searchPeersByHash("),
|
||||
"@PATH@": extract_braced(source, "bool MyMesh::onPeerPathRecv("),
|
||||
@@ -411,12 +515,24 @@ class RepeaterGuestAccessTests(unittest.TestCase):
|
||||
def test_peer_request_and_path_admission(self):
|
||||
self.scenario("receive")
|
||||
|
||||
def test_login_history_records_actual_authorization_results(self):
|
||||
self.scenario("history")
|
||||
|
||||
def test_only_login_requests_enter_history(self):
|
||||
self.scenario("anonymous")
|
||||
|
||||
def test_password_bytes_are_not_logged(self):
|
||||
login = extract_braced(self.source_text, "uint8_t MyMesh::handleLoginReq(")
|
||||
debug_calls = re.findall(r"MESH_DEBUG_PRINTLN\([\s\S]*?\);", login)
|
||||
self.assertFalse(any(re.search(r"\bdata\b", call) for call in debug_calls), debug_calls)
|
||||
|
||||
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)
|
||||
self.assertEqual(body.count("mesh::cli::handleLoginsGet(login_history, command, reply, 157)"), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user