Add recent repeater login history with short key prefixes

This commit is contained in:
mikecarper committed 2026-10-21 13:31:03 -07:00
1 parent c433b40e51
commit f68d4ca544
16 files changed
+657 -12

No files matched your search

+1
View File
@@ -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
+38
View File
@@ -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
+11
View File
@@ -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
+15
View File
@@ -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.
+12 -3
View File
@@ -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] == ' ')) {
+2
View File
@@ -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;
+1
View File
@@ -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},
+141
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+5 -2
View File
@@ -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):
+3 -2
View File
@@ -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(",
+37
View File
@@ -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()
+2
View File
@@ -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) {
+117 -1
View File
@@ -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__":