mirror of https://github.com/meshcore-dev/MeshCore
Browse Source
A repeater already relays every public-channel (GRP_TXT) flood packet that passes through it. This keeps a bounded, in-RAM ring buffer of those packets so a client that was out of range can pull back the messages it missed, using an anonymous request (ANON_REQ sub-type 0x04) -- no login. Design points: - Repeater-only. Room server untouched. - The repeater holds no channel key. Packets are cached and replayed still channel-encrypted, so a non-member who asks receives ciphertext it cannot read, and a member decrypts with the key it already has. - Served directly to the requesting client, never re-flooded, so it adds no broadcast airtime. - No login: public channel data is not admin-only. Rate-limited by the existing anon_limiter, like the other anonymous requests. - Bounded RAM (32 entries, ~6 KB), nothing persisted -- a rotating cache in the small internal-flash FS would be a wear anti-pattern. - Channel broadcasts only, never direct messages, so this does not reintroduce the ACK/timeout problem that closed #613. The request params {channel_hash, since_ts, max_count} are optional and self-describing via a leading length byte, so a stock client that sends only a reply path gets sensible defaults. A plain length check cannot work here: the ANON_REQ plaintext is zero-padded to the AES block size, so the decrypted length exceeds what the sender wrote. The reply is capped at 160 bytes. reply_data is MAX_PACKET_PAYLOAD (184), but the binding limit is the companion's host serial frame -- {push code}{reserved} {tag:4} plus the reply padded to the AES block size minus 4, against MAX_FRAME_SIZE of 176. Larger replies are transmitted but dropped before the app sees them. Clients page for the remainder with `since`. ChannelHistory is a hardware-independent template, covered by 11 googletest cases under a dedicated native env.pull/3078/head
6 changed files with 453 additions and 2 deletions
@ -0,0 +1,121 @@ |
|||
#pragma once |
|||
|
|||
#include <stdint.h> |
|||
#include <string.h> |
|||
|
|||
// Hardware-independent store-and-forward cache of recent public-channel (GRP_TXT)
|
|||
// broadcasts. A repeater already relays every channel packet that passes through
|
|||
// it; this keeps a bounded copy so a companion that was out of range can pull back
|
|||
// what it missed.
|
|||
//
|
|||
// The repeater holds no channel key, so packets are stored - and later replayed -
|
|||
// still encrypted; the requesting client decrypts them with its own channel key.
|
|||
// A cached entry's raw[0] is the GRP_TXT channel-hash prefix.
|
|||
//
|
|||
// Kept free of Arduino / radio dependencies so it can be unit-tested natively
|
|||
// (see test/test_channel_history).
|
|||
//
|
|||
// NUM_ENTRIES : ring-buffer depth (oldest entry evicted when full)
|
|||
// MAX_RAW : largest packet payload cached; must be small enough that one entry
|
|||
// fits inside a single response packet (see serve()).
|
|||
template <int NUM_ENTRIES, int MAX_RAW> |
|||
class ChannelHistory { |
|||
public: |
|||
// per-served-record header: recv_ts(4) + raw_len(1)
|
|||
static const int REC_HEADER = 5; |
|||
|
|||
ChannelHistory() { clear(); } |
|||
|
|||
void clear() { |
|||
memset(_entries, 0, sizeof(_entries)); |
|||
_next_idx = 0; |
|||
} |
|||
|
|||
// Cache one public-channel payload (caller has already checked it is GRP_TXT).
|
|||
// Returns true if newly stored, false if skipped: empty, too large to ever replay
|
|||
// in one response, or a duplicate re-flood already held.
|
|||
//
|
|||
// Dedup is by content: a GRP_TXT payload embeds the sender's encrypted timestamp,
|
|||
// so identical bytes mean the very same packet; distinct messages never collide.
|
|||
bool capture(const uint8_t* payload, uint16_t payload_len, uint32_t now_ts) { |
|||
if (payload_len < 1 || payload_len > MAX_RAW) return false; |
|||
|
|||
for (int k = 0; k < NUM_ENTRIES; k++) { |
|||
const Entry& e = _entries[k]; |
|||
if (e.recv_timestamp != 0 && e.raw_len == payload_len |
|||
&& memcmp(e.raw, payload, payload_len) == 0) { |
|||
return false; // already cached
|
|||
} |
|||
} |
|||
|
|||
Entry& slot = _entries[_next_idx]; |
|||
slot.recv_timestamp = now_ts; |
|||
slot.raw_len = (uint8_t)payload_len; |
|||
memcpy(slot.raw, payload, payload_len); |
|||
_next_idx = (_next_idx + 1) % NUM_ENTRIES; |
|||
return true; |
|||
} |
|||
|
|||
// Pack matching cached packets into out[], oldest -> newest, for entries strictly
|
|||
// newer than since_ts, up to max_count (0 == no explicit cap), stopping early if
|
|||
// the next record would not fit in out_cap. Each record is:
|
|||
// [recv_ts(4)][raw_len(1)][raw ...]
|
|||
// The client pages by re-requesting with since_ts = the newest recv_ts it received.
|
|||
// Writes the number of records emitted to out_count; returns bytes written.
|
|||
int serve(uint8_t want_hash, uint32_t since_ts, uint8_t max_count, |
|||
uint8_t* out, int out_cap, uint8_t& out_count) const { |
|||
if (max_count == 0) max_count = 255; |
|||
uint8_t count = 0; |
|||
int ofs = 0; |
|||
// _next_idx points at the oldest slot once the ring has wrapped, so walking
|
|||
// forward from it yields oldest -> newest; empty slots (recv_timestamp==0) skip.
|
|||
for (int k = 0; k < NUM_ENTRIES && count < max_count; k++) { |
|||
const Entry& e = _entries[(_next_idx + k) % NUM_ENTRIES]; |
|||
if (e.recv_timestamp == 0 || e.recv_timestamp <= since_ts) continue; |
|||
if (want_hash != 0xFF && e.raw[0] != want_hash) continue; // channel filter
|
|||
if (ofs + REC_HEADER + e.raw_len > out_cap) break; // full; client pages for more
|
|||
memcpy(&out[ofs], &e.recv_timestamp, 4); ofs += 4; |
|||
out[ofs++] = e.raw_len; |
|||
memcpy(&out[ofs], e.raw, e.raw_len); ofs += e.raw_len; |
|||
count++; |
|||
} |
|||
out_count = count; |
|||
return ofs; |
|||
} |
|||
|
|||
// Number of non-empty entries currently held (for diagnostics/debug dumps).
|
|||
int countStored() const { |
|||
int n = 0; |
|||
for (int k = 0; k < NUM_ENTRIES; k++) { |
|||
if (_entries[k].recv_timestamp != 0) n++; |
|||
} |
|||
return n; |
|||
} |
|||
|
|||
// Read the i-th non-empty entry oldest -> newest (for diagnostics). Returns false
|
|||
// past the end. channel_hash / raw_len / recv_ts are optional out-params.
|
|||
bool entryAt(int i, uint8_t* channel_hash, uint8_t* raw_len, uint32_t* recv_ts) const { |
|||
int seen = 0; |
|||
for (int k = 0; k < NUM_ENTRIES; k++) { |
|||
const Entry& e = _entries[(_next_idx + k) % NUM_ENTRIES]; |
|||
if (e.recv_timestamp == 0) continue; |
|||
if (seen == i) { |
|||
if (channel_hash) *channel_hash = e.raw[0]; |
|||
if (raw_len) *raw_len = e.raw_len; |
|||
if (recv_ts) *recv_ts = e.recv_timestamp; |
|||
return true; |
|||
} |
|||
seen++; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private: |
|||
struct Entry { |
|||
uint32_t recv_timestamp; // 0 == empty slot
|
|||
uint8_t raw_len; |
|||
uint8_t raw[MAX_RAW]; // GRP_TXT payload, verbatim (still encrypted); raw[0] = channel hash
|
|||
}; |
|||
Entry _entries[NUM_ENTRIES]; |
|||
uint8_t _next_idx; |
|||
}; |
|||
@ -0,0 +1,173 @@ |
|||
#include <gtest/gtest.h> |
|||
|
|||
#include <cstdint> |
|||
#include <cstring> |
|||
#include <string> |
|||
#include <vector> |
|||
|
|||
#include "helpers/ChannelHistory.h" |
|||
|
|||
// Build a fake GRP_TXT payload: raw[0] = channel hash, followed by a body. The real
|
|||
// firmware never inspects the body (it's ciphertext); these tests only care that the
|
|||
// bytes round-trip verbatim and that dedup is by exact content.
|
|||
static std::vector<uint8_t> mkPkt(uint8_t channel_hash, const std::string& body) { |
|||
std::vector<uint8_t> p; |
|||
p.push_back(channel_hash); |
|||
p.insert(p.end(), body.begin(), body.end()); |
|||
return p; |
|||
} |
|||
|
|||
static bool cap(ChannelHistory<8, 64>& h, uint8_t chan, const std::string& body, uint32_t ts) { |
|||
auto p = mkPkt(chan, body); |
|||
return h.capture(p.data(), (uint16_t)p.size(), ts); |
|||
} |
|||
|
|||
// -------- capture / dedup --------
|
|||
|
|||
TEST(ChannelHistory, CapturesAndCounts) { |
|||
ChannelHistory<8, 64> h; |
|||
EXPECT_EQ(h.countStored(), 0); |
|||
EXPECT_TRUE(cap(h, 0xAA, "hello", 100)); |
|||
EXPECT_EQ(h.countStored(), 1); |
|||
} |
|||
|
|||
TEST(ChannelHistory, DedupsIdenticalReflood) { |
|||
ChannelHistory<8, 64> h; |
|||
EXPECT_TRUE (cap(h, 0xAA, "same-bytes", 100)); |
|||
EXPECT_FALSE(cap(h, 0xAA, "same-bytes", 105)); // re-flood heard from another neighbour
|
|||
EXPECT_FALSE(cap(h, 0xAA, "same-bytes", 110)); |
|||
EXPECT_EQ(h.countStored(), 1); |
|||
} |
|||
|
|||
TEST(ChannelHistory, DistinctMessagesBothStored) { |
|||
ChannelHistory<8, 64> h; |
|||
EXPECT_TRUE(cap(h, 0xAA, "msg-one", 100)); |
|||
EXPECT_TRUE(cap(h, 0xAA, "msg-two", 101)); // same channel, different body
|
|||
EXPECT_TRUE(cap(h, 0xBB, "msg-one", 102)); // same body, different channel prefix
|
|||
EXPECT_EQ(h.countStored(), 3); |
|||
} |
|||
|
|||
TEST(ChannelHistory, RejectsEmptyAndOversize) { |
|||
ChannelHistory<8, 64> h; |
|||
uint8_t one = 0x01; |
|||
EXPECT_FALSE(h.capture(&one, 0, 100)); // empty
|
|||
std::vector<uint8_t> big(65, 0x7); // > MAX_RAW (64)
|
|||
EXPECT_FALSE(h.capture(big.data(), (uint16_t)big.size(), 100)); |
|||
std::vector<uint8_t> fits(64, 0x7); // exactly MAX_RAW
|
|||
EXPECT_TRUE(h.capture(fits.data(), (uint16_t)fits.size(), 100)); |
|||
EXPECT_EQ(h.countStored(), 1); |
|||
} |
|||
|
|||
// -------- ring wrap / eviction --------
|
|||
|
|||
TEST(ChannelHistory, EvictsOldestOnWrap) { |
|||
ChannelHistory<4, 64> h; |
|||
for (int i = 0; i < 6; i++) { // 6 into a depth-4 ring
|
|||
auto p = mkPkt(0xAA, "m" + std::to_string(i)); |
|||
ASSERT_TRUE(h.capture(p.data(), (uint16_t)p.size(), 100 + i)); |
|||
} |
|||
EXPECT_EQ(h.countStored(), 4); // capped at depth
|
|||
uint32_t ts = 0; |
|||
ASSERT_TRUE(h.entryAt(0, nullptr, nullptr, &ts)); |
|||
EXPECT_EQ(ts, 102u); // m0,m1 evicted; oldest survivor is m2
|
|||
} |
|||
|
|||
// -------- serve --------
|
|||
|
|||
// Decode a serve() buffer into (recv_ts, raw) records for assertions.
|
|||
struct Rec { uint32_t ts; std::vector<uint8_t> raw; }; |
|||
static std::vector<Rec> decode(const uint8_t* buf, int len) { |
|||
std::vector<Rec> out; |
|||
int ofs = 0; |
|||
while (ofs + 5 <= len) { |
|||
Rec r; |
|||
memcpy(&r.ts, &buf[ofs], 4); ofs += 4; |
|||
uint8_t rl = buf[ofs++]; |
|||
r.raw.assign(&buf[ofs], &buf[ofs + rl]); ofs += rl; |
|||
out.push_back(r); |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
TEST(ChannelHistory, ServeReturnsNewerThanSinceInOrder) { |
|||
ChannelHistory<8, 64> h; |
|||
cap(h, 0xAA, "a", 100); |
|||
cap(h, 0xAA, "b", 200); |
|||
cap(h, 0xAA, "c", 300); |
|||
|
|||
uint8_t buf[256]; uint8_t count = 0; |
|||
int n = h.serve(0xFF, 150, 0, buf, sizeof(buf), count); |
|||
auto recs = decode(buf, n); |
|||
ASSERT_EQ(count, 2); |
|||
ASSERT_EQ(recs.size(), 2u); |
|||
EXPECT_EQ(recs[0].ts, 200u); // oldest-first, and 'a'@100 excluded by since=150
|
|||
EXPECT_EQ(recs[1].ts, 300u); |
|||
EXPECT_EQ(std::string(recs[1].raw.begin() + 1, recs[1].raw.end()), "c"); |
|||
} |
|||
|
|||
TEST(ChannelHistory, ServeFiltersByChannelHash) { |
|||
ChannelHistory<8, 64> h; |
|||
cap(h, 0xAA, "on-aa", 100); |
|||
cap(h, 0xBB, "on-bb", 101); |
|||
cap(h, 0xAA, "on-aa-2", 102); |
|||
|
|||
uint8_t buf[256]; uint8_t count = 0; |
|||
int n = h.serve(0xAA, 0, 0, buf, sizeof(buf), count); |
|||
auto recs = decode(buf, n); |
|||
ASSERT_EQ(count, 2); |
|||
for (auto& r : recs) EXPECT_EQ(r.raw[0], 0xAA); |
|||
} |
|||
|
|||
TEST(ChannelHistory, ServeRespectsMaxCount) { |
|||
ChannelHistory<8, 64> h; |
|||
for (int i = 0; i < 5; i++) cap(h, 0xAA, "m" + std::to_string(i), 100 + i); |
|||
uint8_t buf[256]; uint8_t count = 0; |
|||
h.serve(0xFF, 0, 2, buf, sizeof(buf), count); |
|||
EXPECT_EQ(count, 2); |
|||
} |
|||
|
|||
TEST(ChannelHistory, ServePagesByAdvancingSince) { |
|||
ChannelHistory<8, 64> h; |
|||
for (int i = 0; i < 5; i++) cap(h, 0xAA, "m" + std::to_string(i), 100 + i); |
|||
|
|||
uint8_t buf[256]; uint8_t count = 0; |
|||
int n = h.serve(0xFF, 0, 2, buf, sizeof(buf), count); // page 1
|
|||
auto page1 = decode(buf, n); |
|||
ASSERT_EQ(count, 2); |
|||
uint32_t cursor = page1.back().ts; |
|||
|
|||
n = h.serve(0xFF, cursor, 2, buf, sizeof(buf), count); // page 2
|
|||
auto page2 = decode(buf, n); |
|||
ASSERT_EQ(count, 2); |
|||
EXPECT_GT(page2.front().ts, cursor); // strictly newer; no overlap
|
|||
} |
|||
|
|||
TEST(ChannelHistory, ServeStopsWhenBufferFull) { |
|||
ChannelHistory<8, 64> h; |
|||
std::vector<uint8_t> body(60, 0x9); // record = 5 + 61 = 66 bytes
|
|||
for (int i = 0; i < 4; i++) { |
|||
std::vector<uint8_t> p; p.push_back(0xAA); |
|||
p.insert(p.end(), body.begin(), body.end()); |
|||
p[1] = (uint8_t)i; // make each distinct
|
|||
ASSERT_TRUE(h.capture(p.data(), (uint16_t)p.size(), 100 + i)); |
|||
} |
|||
uint8_t buf[140]; uint8_t count = 0; // fits only 2 records (132 bytes)
|
|||
int n = h.serve(0xFF, 0, 0, buf, sizeof(buf), count); |
|||
EXPECT_EQ(count, 2); // partial; client pages for the rest
|
|||
EXPECT_LE(n, (int)sizeof(buf)); |
|||
} |
|||
|
|||
TEST(ChannelHistory, ClearEmpties) { |
|||
ChannelHistory<8, 64> h; |
|||
cap(h, 0xAA, "x", 100); |
|||
h.clear(); |
|||
EXPECT_EQ(h.countStored(), 0); |
|||
uint8_t buf[64]; uint8_t count = 0; |
|||
h.serve(0xFF, 0, 0, buf, sizeof(buf), count); |
|||
EXPECT_EQ(count, 0); |
|||
} |
|||
|
|||
int main(int argc, char** argv) { |
|||
::testing::InitGoogleTest(&argc, argv); |
|||
return RUN_ALL_TESTS(); |
|||
} |
|||
Loading…
Reference in new issue