Harden firmware recovery and retries

Improve companion BLE delivery and idempotent remote CLI retries, make FPF7 persistence atomic with FPF6 migration, verify staged OTA data on resume, and clarify filter policy wildcard and BlackHole semantics.
This commit is contained in:
mikecarper
2026-08-07 16:24:53 -07:00
parent 7f56da7a34
commit 11b692abfa
37 changed files with 1666 additions and 307 deletions
+2 -1
View File
@@ -141,6 +141,7 @@
if (optional) return "";
throw new FilterToolError("Channel is required.");
}
if (channel === "*") return "";
if (channel.toLowerCase() === "public") return "public";
if (channel[0] === "#") {
if (channel.length < 2 || channel.length > 31 || /\s/.test(channel)) {
@@ -149,7 +150,7 @@
return channel;
}
if (/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(channel)) return channel.toUpperCase();
throw new FilterToolError("Channel must be public, #channel, or a 128/256-bit hexadecimal key.");
throw new FilterToolError("Channel must be *, public, #channel, or a 128/256-bit hexadecimal key.");
}
function normalizeScopeName(value) {
+15 -8
View File
@@ -1665,9 +1665,11 @@ compile this table.
- `all`: Match every received hop count (`0-63`).
- `0+`, `all`, and an omitted hop expression are equivalent. The CLI displays
the saved range as `all`.
- `channel=*|public|#name|128-bit-key|256-bit-key`: Optional authenticated
group-channel match. It applies only to `GRP_TXT`/`GRP_DATA`; `type=any`
plus a channel condition therefore matches only those group types.
- `channel=*|public|#name|128-bit-key|256-bit-key`: Optional channel match.
`channel=*` means no channel condition at all, so the row matches everything
selected by `type=` (including all flood payload types with `type=any`). It
does not authenticate a packet. `public`, `#name`, and raw keys authenticate
one channel and therefore narrow the row to `GRP_TXT`/`GRP_DATA`.
- `prefix=<ID[,ID...]>`: Optional ordered source-path prefix of one to three
pbyte IDs. IDs must all be 2, 4, or 6 hex characters, matching a packet's
1-, 2-, or 3-byte pbyte width. `path=<prefix>` is an alias.
@@ -1678,7 +1680,12 @@ compile this table.
- `drop`: Explicit drop action. The `flood.rule` form requires an explicit
action. For compatibility, a legacy `flood.filter` row with no rewrite,
rate, or stop action is treated as drop.
- `region=<name>`: Rewrite action using an existing locally allowed region.
- `scope=<name>`: Direct public-name scope rewrite. It derives a transport key
from the name and does not require a configured region. For example,
`scope=BlackHole86` is a regionless sink scope; `region=BlackHole86` would
instead require a real configured, flood-allowed region with that name.
- `region=<name>`: Rewrite using an existing locally allowed region and one of
that region's transport keys.
- `rate=N/min`: Per-node, per-row fixed one-minute forwarding limit. It can be
the only action or accompany `scope=`/`region=`. Counters are charged only
for packets that pass all forwarding gates.
@@ -1687,12 +1694,12 @@ compile this table.
- `stop` or `action=stop`: Apply this matching row, then stop lower-order FPF7
rows from processing. It can stand alone or accompany drop, rewrite, or
rate. A stop-only row acts as an exception to lower-priority FPF7 rules.
If the same row uses `region=` and that configured region is missing, denied,
wildcard, or has no usable transport key, both the rewrite and its `stop`
are inert so lower-order safety rows still run. A direct `scope=` target does
not depend on region configuration.
- `suspend=tempradio`: Optional. Skip this row only while the temporary radio
is actually active.
- `scope=<name>`: Optional scope-setting action. The name is normalized with a
leading `#` and its 128-bit transport key is derived directly from that
hashtag. It does not need to exist in the region list. Public names up to 30
characters are accepted; private `$` scopes are not.
- `require=region`: Legacy alias for `in=allowed`. Apply the row only if the
original incoming packet already passes this repeater's
region gate. An incoming transport scope must resolve to a locally allowed
+2 -2
View File
@@ -64,7 +64,7 @@ in the simulator below. The examples draw from
</thead>
<tbody>
<tr><td><code>hops=</code></td><td>Received hop count: <code>all</code>, <code>3+</code>, <code>2-6</code>, or <code>3</code></td></tr>
<tr><td><code>channel=</code></td><td>Group channel</td></tr>
<tr><td><code>channel=</code></td><td><code>*</code> means no channel condition; a name or key authenticates one group channel</td></tr>
<tr><td><code>rx.scope=</code></td><td>Original incoming transport scope</td></tr>
<tr><td><code>path=</code></td><td>Path prefix, blacklist, bucket, or loop match</td></tr>
<tr><td><code>tempradio=</code></td><td>Temporary-radio state</td></tr>
@@ -163,7 +163,7 @@ in the simulator below. The examples draw from
</label>
<label>
Channel (optional)
<input data-field="channel" placeholder="#rgdata, public, or key">
<input data-field="channel" placeholder="*, #rgdata, public, or key">
</label>
<label>
Original incoming scope
+13 -8
View File
@@ -325,10 +325,12 @@ Match fields:
`flood.filter`. The positional form remains accepted.
- `hops=` accepts `all`, `N`, `N+`, or `N-M`. The positional form remains
accepted. Received hops over 3 are written as `hops=4+`.
- `channel=*|public|#name|128-bit-key|256-bit-key` authenticates a `GRP_TXT` or
`GRP_DATA` packet with that channel key before the row can match. With
`type=any`, this condition naturally limits the row to those two group
packet types.
- `channel=*|public|#name|128-bit-key|256-bit-key` optionally narrows by
channel. `channel=*` is an unconstrained wildcard: it performs no channel
authentication and matches every payload selected by `type=`. Thus
`type=any channel=*` means every flood payload type. `public`, `#name`, and
raw keys authenticate one channel and narrow the row to `GRP_TXT` or
`GRP_DATA`.
- `prefix=` is a source-path prefix containing one to three comma-separated
pbyte IDs. Every ID must use the packet's pbyte width: 2, 4, or 6 hex
characters for 1-, 2-, or 3-byte paths. Order matters and matching begins at
@@ -348,10 +350,13 @@ Actions:
`flood.rule` form requires an explicit action. For backward compatibility,
only a legacy `flood.filter` row with no rewrite, rate, or stop action means
drop implicitly.
- `scope=<name>` assigns a direct public hashtag scope without requiring a
region-list entry.
- `region=<name>` assigns an existing locally allowed region and its transport
key.
- `scope=<name>` derives a public transport scope directly from the name; no
region entry is consulted. `scope=BlackHole86` is therefore a valid
regionless sink. `region=<name>` is different: it resolves a configured,
flood-allowed region and one of that region's transport keys.
- A `stop` on a row whose `region=` target is currently unusable is also inert,
allowing lower-priority safety rules to run. Direct `scope=` targets do not
have this configuration dependency.
- `rate=N/min` is a per-node, per-row fixed one-minute forwarding limit. It can
stand alone or accompany a scope/region rewrite. Quota is charged only after
every other forwarding gate, including moderation, accepts the packet. It is
+1 -1
View File
@@ -85,7 +85,7 @@ set flood.retry.ignore none
| `flood.channel.scope` | Adds a transport scope to received unscoped floods or replaces the scope of already-scoped floods. A bare target uses an existing flood-allowed region; `scope=<name>` derives a public hashtag target directly without creating a region, exactly like `flood.filter scope=`. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. `path=blacklist` and `path=bucket:<1-6>` make a row path-qualified; bridge buckets remain usable while bridge retry is off. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; path-qualified rows beat the ordinary channel fallback. `login:*` covers the remote-login family, and `other:*` covers every remaining flood type, including flood-form TRACE and OTA. Direct traceroute remains outside the flood table. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region|scope=name> [path=blacklist|path=bucket:1-6] [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope #rgdata scope=BlackHole86` |
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <public|#channel|key>`, `del flood.channel.scope.require.<n>|all` | `set flood.channel.scope.require #bot` |
| `flood.filter` | Persistent flood-route rules selected by payload type and optional hop range. Extended builds have 31 FPF7 slots and add authenticated channel, ordered pbyte prefix, original scope/region, rewrite, rate, priority, and terminal stop fields; `flood.rule` is an alias for the same table. Repeaters also support a separate unordered `path=blacklist`. FULL ESP32 room servers have the same 31 extended slots but no blacklist and require remote administrator access. Fixed 240 KB STM32WL repeaters retain compact FPF6 filtering and blacklist commands. New generalized repeater tables seed slot 1 with `ota all suspend=tempradio` and slot 2 with an authenticated `#wardriving hops=5+` drop. Direct routes and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all`; repeater only: `get/set/del flood.filter.blacklist[.n]` | `set flood.filter grp_txt all scope=local tx=slow` |
| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, authenticated group channel, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, and/or stop lower-priority FPF7 rules. All rows match the original receive-time packet; higher `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region name makes that match or rewrite inert until the name returns. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|stop> [priority=0-255]`, `del flood.rule.<n>|all` | `set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86` |
| `flood.rule` | Live alias for extended `flood.filter` on rule-engine repeaters and FULL ESP32 room servers. A row can AND packet type, hop range, optional channel authentication, ordered 1/2/3-byte pbyte source prefix, and original scope/region conditions, then drop, rewrite to a direct scope or configured region, enforce a per-row rate, and/or stop lower-priority FPF7 rules. `channel=*` means no channel condition and `type=any channel=*` matches every flood payload type. `scope=BlackHole86` directly derives a regionless sink scope; `region=BlackHole86` would require a configured flood-allowed region. All rows match the original receive-time packet; higher `priority` runs first and lower slot breaks a tie. Repeated rows with one channel key share a per-packet authentication result. Persistent FPF7 stores canonical region names, so region ID reorder or reuse cannot retarget a rule. A missing saved region makes its `region=` rewrite and paired `stop` inert until the name returns, allowing lower safety rows to run. Fixed 240 KB STM32WL profiles keep FPF6 and do not expose this alias; partition sizes are unchanged. | `get flood.rule[.n]`, `set flood.rule[.n] type=<type> [hops=...] [channel=...] [prefix=...] [in=...] <drop|scope=...|region=...|rate=N/min|stop> [priority=0-255]`, `del flood.rule.<n>|all` | `set flood.rule.2 type=grp_data hops=4+ channel=#rgdata in=none scope=BlackHole86` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so `repeat off` and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
+3 -1
View File
@@ -2017,8 +2017,10 @@ void MyMesh::handleCmdFrame(size_t len) {
int result;
uint32_t expected_ack;
if (txt_type == TXT_TYPE_CLI_DATA) {
const uint32_t logical_request_id = msg_timestamp;
msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection
result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout);
result = sendCommandData(*recipient, msg_timestamp, attempt, text,
est_timeout, logical_request_id);
expected_ack = 0; // no Ack expected
} else {
const uint32_t app_timestamp = msg_timestamp;
+227 -88
View File
@@ -110,6 +110,8 @@ extern "C" caddr_t _sbrk(int increment);
#define LEGACY_FLOOD_CHANNEL_BLOCK_NAME_LEN 32
#define LEGACY_FLOOD_CHANNEL_BLOCK_HOPS_INHERIT 0xFE
#define FLOOD_PACKET_FILTER_FILE "/flood_filter"
#define FLOOD_PACKET_FILTER_TEMP_FILE "/flood_filter.tmp"
#define FLOOD_PACKET_FILTER_BACKUP_FILE "/flood_filter.bak"
#define FLOOD_PACKET_FILTER_BLACKLIST_FILE "/flood_filter_bl"
#define FLOOD_CHANNEL_SCOPE_FILE "/flood_ch_scope"
#define FLOOD_CHANNEL_SCOPE_TEMP_FILE "/flood_ch_scope.tmp"
@@ -373,6 +375,39 @@ static File openFloodSettingsWrite(FILESYSTEM* fs, const char* filename) {
#endif
}
static uint32_t updateFloodSettingsHash(uint32_t hash,
const uint8_t* data, size_t len) {
while (len-- > 0) {
hash ^= *data++;
hash *= 16777619UL;
}
return hash;
}
static bool verifyFloodSettingsWrite(FILESYSTEM* fs, const char* filename,
size_t expected_size,
uint32_t expected_hash) {
File file = openFloodSettingsRead(fs, filename);
if (!file || file.size() != expected_size) {
if (file) file.close();
return false;
}
uint32_t hash = 2166136261UL;
uint8_t buffer[64];
size_t remaining = expected_size;
while (remaining > 0) {
size_t amount = remaining < sizeof(buffer) ? remaining : sizeof(buffer);
if (file.read(buffer, amount) != amount) {
file.close();
return false;
}
hash = updateFloodSettingsHash(hash, buffer, amount);
remaining -= amount;
}
file.close();
return hash == expected_hash;
}
static uint8_t batteryPercentFromMilliVolts(uint16_t batt_mv) {
const int min_mv = BATT_MIN_MILLIVOLTS;
const int max_mv = BATT_MAX_MILLIVOLTS;
@@ -2546,11 +2581,14 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
char *command = (char *)&data[5];
size_t command_len = strlen(command);
uint32_t request_id = sender_timestamp;
mesh::RemoteCliRequest::parse(data, len, 5, request_id);
uint32_t command_fingerprint =
mesh::RemoteCliReplyCache::fingerprint(command, command_len);
const bool cached_retry =
remote_cli_reply_cache.matches(client->id.pub_key, sender_timestamp,
command_fingerprint);
const char* cached_response = NULL;
const bool cached_retry = remote_cli_reply_cache.lookup(
client->id.pub_key, request_id, command_fingerprint,
&cached_response);
// An old exact match may only replay its stored response. Any stale
// mismatch remains blocked by the normal timestamp replay guard.
@@ -2581,17 +2619,24 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
if (cached_retry) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply");
sendRemoteCliReply(client, secret, packet->getPathHashSize(),
sender_timestamp, remote_cli_reply_cache.response(),
sender_timestamp, cached_response,
reply_scoped ? &reply_scope : NULL);
} else if (deferred_cli_command.matches(i, request_id, command,
command_len)) {
// The original request is already queued. Let it produce the one
// authoritative result instead of turning an in-flight retry into a
// spurious busy error.
MESH_DEBUG_PRINTLN("onPeerDataRecv: remote CLI request is already pending");
} else if (repeated_timestamp) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply");
} else if (!deferred_cli_command.enqueue(i, sender_timestamp,
packet->getPathHashSize(), secret,
command, command_len)) {
command, command_len,
request_id)) {
const char* error = deferred_cli_command.pending
? "Err - another remote command is still running"
: "Err - remote command is too long";
remote_cli_reply_cache.remember(client->id.pub_key, sender_timestamp,
remote_cli_reply_cache.remember(client->id.pub_key, request_id,
command_fingerprint, error);
sendRemoteCliReply(client, secret, packet->getPathHashSize(),
sender_timestamp, error,
@@ -2612,7 +2657,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
uint8_t path_hash_size, uint32_t sender_timestamp,
const char* reply, const TransportKey* fallback_scope) {
if (client == NULL || secret == NULL || reply == NULL || reply[0] == 0) return;
if (client == NULL || secret == NULL || reply == NULL) return;
if (reply[0] == 0) reply = "OK";
size_t text_len = strlen(reply);
const size_t max_text_len =
@@ -2697,7 +2743,7 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
deferred_cli_command.command, reply, client_index,
deferred_cli_command.path_hash_size);
remote_cli_reply_cache.remember(client->id.pub_key,
deferred_cli_command.sender_timestamp,
deferred_cli_command.request_id,
command_fingerprint, reply);
sendRemoteCliReply(client, deferred_cli_command.secret,
deferred_cli_command.path_hash_size,
@@ -4774,20 +4820,20 @@ void MyMesh::seedDefaultFloodPacketFilters() {
#if MESH_ENABLE_FLOOD_RULE_ENGINE
bool MyMesh::loadFloodPacketFilters() {
memset(flood_packet_filters, 0, sizeof(flood_packet_filters));
if (_fs == NULL) {
seedDefaultFloodPacketFilters();
return true;
}
if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)) {
seedDefaultFloodPacketFilters();
return saveFloodPacketFilters();
}
File file = openFloodSettingsRead(_fs, FLOOD_PACKET_FILTER_FILE);
if (!file) return false;
enum class FileState : uint8_t { Missing, Valid, Invalid, Unreadable };
auto loadFile = [this](const char* filename) -> FileState {
memset(flood_packet_filters, 0, sizeof(flood_packet_filters));
if (!_fs->exists(filename)) return FileState::Missing;
FloodPacketFilterEntry* loaded = flood_packet_filters;
File file = openFloodSettingsRead(_fs, filename);
if (!file) return FileState::Unreadable;
FloodPacketFilterEntry* loaded = flood_packet_filters;
uint8_t magic[4];
uint8_t count = 0;
bool success = file.read(magic, sizeof(magic)) == sizeof(magic);
@@ -4987,11 +5033,72 @@ bool MyMesh::loadFloodPacketFilters() {
success = false;
}
}
file.close();
file.close();
if (!success) memset(flood_packet_filters, 0,
sizeof(flood_packet_filters));
return success ? FileState::Valid : FileState::Invalid;
};
// A truncated or invalid file fails open; filtering must never be enabled by corrupt bytes.
if (!success) memset(flood_packet_filters, 0, sizeof(flood_packet_filters));
return success;
FileState primary = loadFile(FLOOD_PACKET_FILTER_FILE);
if (primary == FileState::Valid) {
// A valid primary is already committed. Transaction remnants are stale.
if (_fs->exists(FLOOD_PACKET_FILTER_TEMP_FILE))
_fs->remove(FLOOD_PACKET_FILTER_TEMP_FILE);
if (_fs->exists(FLOOD_PACKET_FILTER_BACKUP_FILE))
_fs->remove(FLOOD_PACKET_FILTER_BACKUP_FILE);
return true;
}
if (primary == FileState::Unreadable) {
// The primary name is authoritative. Do not replace a file that the
// filesystem reported but could not open.
return false;
}
FileState temp = loadFile(FLOOD_PACKET_FILTER_TEMP_FILE);
if (temp == FileState::Valid) {
// A complete temp is the newest transaction image. Never destroy an
// unreadable primary, but still use the verified temp in RAM this boot.
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid)
_fs->remove(FLOOD_PACKET_FILTER_FILE);
if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)
&& _fs->rename(FLOOD_PACKET_FILTER_TEMP_FILE,
FLOOD_PACKET_FILTER_FILE)) {
if (_fs->exists(FLOOD_PACKET_FILTER_BACKUP_FILE))
_fs->remove(FLOOD_PACKET_FILTER_BACKUP_FILE);
}
}
return true;
}
FileState backup = loadFile(FLOOD_PACKET_FILTER_BACKUP_FILE);
if (backup == FileState::Valid) {
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid)
_fs->remove(FLOOD_PACKET_FILTER_FILE);
if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)
&& _fs->rename(FLOOD_PACKET_FILTER_BACKUP_FILE,
FLOOD_PACKET_FILTER_FILE)) {
if (temp != FileState::Unreadable
&& _fs->exists(FLOOD_PACKET_FILTER_TEMP_FILE))
_fs->remove(FLOOD_PACKET_FILTER_TEMP_FILE);
}
}
return true;
}
// No complete image survived. Install the normal safe defaults in RAM and
// replace only files proven malformed; unreadable files are preserved.
seedDefaultFloodPacketFilters();
if (primary == FileState::Invalid) _fs->remove(FLOOD_PACKET_FILTER_FILE);
if (temp == FileState::Invalid) _fs->remove(FLOOD_PACKET_FILTER_TEMP_FILE);
if (backup == FileState::Invalid)
_fs->remove(FLOOD_PACKET_FILTER_BACKUP_FILE);
if (primary == FileState::Unreadable || temp == FileState::Unreadable
|| backup == FileState::Unreadable) {
return false;
}
return saveFloodPacketFilters();
}
void MyMesh::migrateLegacyFloodChannelBlocks() {
@@ -5138,13 +5245,30 @@ void MyMesh::migrateLegacyFloodChannelBlocks() {
bool MyMesh::saveFloodPacketFilters() {
if (_fs == NULL) return false;
File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_FILE);
// Recovery owns transaction remnants; overwriting one could erase the only
// complete image after a failed publish boundary.
if (_fs->exists(FLOOD_PACKET_FILTER_TEMP_FILE)
|| _fs->exists(FLOOD_PACKET_FILTER_BACKUP_FILE)) {
return false;
}
File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_TEMP_FILE);
if (!file) return false;
size_t bytes_written = 0;
uint32_t write_hash = 2166136261UL;
auto writeExact = [&file, &bytes_written, &write_hash](
const void* source, size_t len) {
const uint8_t* data = (const uint8_t*)source;
if (file.write(data, len) != len) return false;
bytes_written += len;
write_hash = updateFloodSettingsHash(write_hash, data, len);
return true;
};
const uint8_t magic[4] = {'F', 'P', 'F', '7'};
uint8_t count = FLOOD_PACKET_FILTER_SLOTS;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&count, sizeof(count)) == sizeof(count);
bool success = writeExact(magic, sizeof(magic))
&& writeExact(&count, sizeof(count));
for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) {
const auto& entry = flood_packet_filters[i];
uint8_t active = entry.active ? 1 : 0;
@@ -5158,63 +5282,57 @@ bool MyMesh::saveFloodPacketFilters() {
uint8_t drop_on_match = entry.drop_on_match ? 1 : 0;
uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0;
uint8_t stop_on_match = entry.stop_on_match ? 1 : 0;
success = file.write(&active, sizeof(active)) == sizeof(active);
success = success && file.write(&entry.payload_type, sizeof(entry.payload_type)) == sizeof(entry.payload_type);
success = success && file.write(&entry.min_hops, sizeof(entry.min_hops)) == sizeof(entry.min_hops);
success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops);
success = success && file.write(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio);
success = success && file.write((const uint8_t*)entry.scope_name,
sizeof(entry.scope_name)) == sizeof(entry.scope_name);
success = success && file.write(&match_blacklisted_path,
sizeof(match_blacklisted_path))
== sizeof(match_blacklisted_path);
success = success && file.write(&scope_requires_region_match,
sizeof(scope_requires_region_match))
== sizeof(scope_requires_region_match);
success = success && file.write(&scope_uses_slow_timing,
sizeof(scope_uses_slow_timing))
== sizeof(scope_uses_slow_timing);
success = success && file.write(&entry.incoming_scope_kind,
sizeof(entry.incoming_scope_kind))
== sizeof(entry.incoming_scope_kind);
success = success && file.write(
(const uint8_t*)entry.incoming_scope_name,
sizeof(entry.incoming_scope_name)) == sizeof(entry.incoming_scope_name);
success = success && file.write(&entry.channel_key_len,
sizeof(entry.channel_key_len))
== sizeof(entry.channel_key_len);
success = success && file.write(entry.channel_secret,
sizeof(entry.channel_secret))
== sizeof(entry.channel_secret);
success = success && file.write((const uint8_t*)entry.channel_name,
sizeof(entry.channel_name))
== sizeof(entry.channel_name);
success = success && file.write(&entry.path_hash_size,
sizeof(entry.path_hash_size))
== sizeof(entry.path_hash_size);
success = success && file.write(&entry.path_hops,
sizeof(entry.path_hops))
== sizeof(entry.path_hops);
success = success && file.write(entry.path, sizeof(entry.path))
== sizeof(entry.path);
success = success && file.write(&drop_on_match, sizeof(drop_on_match))
== sizeof(drop_on_match);
success = success && file.write(&rate_limit_enabled,
sizeof(rate_limit_enabled))
== sizeof(rate_limit_enabled);
success = success && file.write((const uint8_t*)&entry.rate_per_minute,
sizeof(entry.rate_per_minute))
== sizeof(entry.rate_per_minute);
success = success && file.write(
(const uint8_t*)entry.target_region_name,
sizeof(entry.target_region_name)) == sizeof(entry.target_region_name);
success = success && file.write(&entry.priority, sizeof(entry.priority))
== sizeof(entry.priority);
success = success && file.write(&stop_on_match, sizeof(stop_on_match))
== sizeof(stop_on_match);
success = writeExact(&active, sizeof(active))
&& writeExact(&entry.payload_type, sizeof(entry.payload_type))
&& writeExact(&entry.min_hops, sizeof(entry.min_hops))
&& writeExact(&entry.max_hops, sizeof(entry.max_hops))
&& writeExact(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio))
&& writeExact(entry.scope_name, sizeof(entry.scope_name))
&& writeExact(&match_blacklisted_path,
sizeof(match_blacklisted_path))
&& writeExact(&scope_requires_region_match,
sizeof(scope_requires_region_match))
&& writeExact(&scope_uses_slow_timing,
sizeof(scope_uses_slow_timing))
&& writeExact(&entry.incoming_scope_kind,
sizeof(entry.incoming_scope_kind))
&& writeExact(entry.incoming_scope_name,
sizeof(entry.incoming_scope_name))
&& writeExact(&entry.channel_key_len, sizeof(entry.channel_key_len))
&& writeExact(entry.channel_secret, sizeof(entry.channel_secret))
&& writeExact(entry.channel_name, sizeof(entry.channel_name))
&& writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size))
&& writeExact(&entry.path_hops, sizeof(entry.path_hops))
&& writeExact(entry.path, sizeof(entry.path))
&& writeExact(&drop_on_match, sizeof(drop_on_match))
&& writeExact(&rate_limit_enabled, sizeof(rate_limit_enabled))
&& writeExact(&entry.rate_per_minute, sizeof(entry.rate_per_minute))
&& writeExact(entry.target_region_name,
sizeof(entry.target_region_name))
&& writeExact(&entry.priority, sizeof(entry.priority))
&& writeExact(&stop_on_match, sizeof(stop_on_match));
}
file.close();
return success;
if (!success || !verifyFloodSettingsWrite(
_fs, FLOOD_PACKET_FILTER_TEMP_FILE, bytes_written, write_hash)) {
_fs->remove(FLOOD_PACKET_FILTER_TEMP_FILE);
return false;
}
if (_fs->exists(FLOOD_PACKET_FILTER_FILE)
&& !_fs->rename(FLOOD_PACKET_FILTER_FILE,
FLOOD_PACKET_FILTER_BACKUP_FILE)) {
_fs->remove(FLOOD_PACKET_FILTER_TEMP_FILE);
return false;
}
if (!_fs->rename(FLOOD_PACKET_FILTER_TEMP_FILE,
FLOOD_PACKET_FILTER_FILE)) {
// Keep both the verified new image and the old backup for boot recovery.
return false;
}
if (_fs->exists(FLOOD_PACKET_FILTER_BACKUP_FILE))
_fs->remove(FLOOD_PACKET_FILTER_BACKUP_FILE);
return true;
}
#else
bool MyMesh::loadFloodPacketFilters() {
@@ -5434,12 +5552,37 @@ int MyMesh::nextFloodPacketFilterMatch(uint32_t match_mask,
match_mask, visited_mask, priorities, FLOOD_PACKET_FILTER_SLOTS);
}
uint32_t MyMesh::applyFloodPacketFilterStop(uint32_t match_mask) const {
bool MyMesh::resolveFloodPacketFilterTargetRegion(
const char* name, TransportKey& scope,
const char*& canonical_name) {
if (name == NULL || name[0] == 0) return false;
RegionEntry* region = region_map.findByName(name);
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| region_map.getTransportKeysFor(*region, &scope, 1) <= 0
|| scope.isNull()) {
return false;
}
canonical_name = region->name;
return true;
}
uint32_t MyMesh::applyFloodPacketFilterStop(uint32_t match_mask) {
uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS];
uint8_t stop_flags[FLOOD_PACKET_FILTER_SLOTS];
for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) {
priorities[i] = flood_packet_filters[i].priority;
stop_flags[i] = flood_packet_filters[i].stop_on_match ? 1 : 0;
const auto& entry = flood_packet_filters[i];
bool region_usable = true;
if (entry.target_region_name[0] != 0) {
TransportKey scope;
const char* canonical_name = NULL;
region_usable = resolveFloodPacketFilterTargetRegion(
entry.target_region_name, scope, canonical_name);
}
stop_flags[i] = FloodFilterPolicy::stopActionApplies(
entry.stop_on_match, entry.target_region_name[0] != 0,
region_usable) ? 1 : 0;
}
return FloodFilterPolicy::truncateRulesAtStop(
match_mask, priorities, stop_flags, FLOOD_PACKET_FILTER_SLOTS);
@@ -5447,7 +5590,7 @@ uint32_t MyMesh::applyFloodPacketFilterStop(uint32_t match_mask) const {
uint32_t MyMesh::evaluateFloodPacketFilterMatches(
const mesh::Packet* packet, bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
const RegionEntry* incoming_region) {
static_assert(FLOOD_PACKET_FILTER_SLOTS <= 32,
"flood filter match mask supports at most 32 slots");
if (packet == NULL || !packet->isRouteFlood()) return 0;
@@ -5514,14 +5657,10 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
if (entry.scope_name[0] != 0) {
deriveFloodFilterScopeKey(entry.scope_name, scope);
} else {
RegionEntry* region = region_map.findByName(entry.target_region_name);
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| region_map.getTransportKeysFor(*region, &scope, 1) <= 0
|| scope.isNull()) {
if (!resolveFloodPacketFilterTargetRegion(
entry.target_region_name, scope, target_name)) {
continue;
}
target_name = region->name;
}
uint16_t transport_code = scope.calcTransportCode(packet);
bool scope_changed =
@@ -5612,7 +5751,7 @@ bool MyMesh::floodPacketFilterFieldsMatch(
uint32_t MyMesh::evaluateFloodPacketFilterMatches(
const mesh::Packet* packet, bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
const RegionEntry* incoming_region) {
(void)incoming_region;
uint32_t matches = 0;
for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) {
+6 -2
View File
@@ -74,6 +74,7 @@
#include <helpers/CommonCLI.h>
#include <helpers/DeferredCliCommand.h>
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
#include <helpers/UserGpioReplyTracker.h>
#endif
@@ -601,10 +602,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
const mesh::Packet* packet) const;
int nextFloodPacketFilterMatch(uint32_t match_mask,
uint32_t visited_mask) const;
uint32_t applyFloodPacketFilterStop(uint32_t match_mask) const;
bool resolveFloodPacketFilterTargetRegion(
const char* name, TransportKey& scope,
const char*& canonical_name);
uint32_t applyFloodPacketFilterStop(uint32_t match_mask);
uint32_t evaluateFloodPacketFilterMatches(
const mesh::Packet* packet, bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
const RegionEntry* incoming_region);
bool applyFloodPacketFilterScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change = true);
+150 -30
View File
@@ -11,6 +11,8 @@
namespace {
static const char RULE_FILE[] = "/flood_filter";
static const char RULE_TEMP_FILE[] = "/flood_filter.tmp";
static const char RULE_BACKUP_FILE[] = "/flood_filter.bak";
static const char RULE_USAGE[] =
"Err - use: set flood.rule[.n] type=<type> [hops=<range>] [...]";
static const char DUPLICATE_OPTION[] = "Err - duplicate filter option";
@@ -345,6 +347,39 @@ static File openWrite(FILESYSTEM* fs, const char* path) {
return fs->open(path, "w", true);
}
static uint32_t updateFileHash(uint32_t hash,
const uint8_t* data, size_t len) {
while (len-- > 0) {
hash ^= *data++;
hash *= 16777619UL;
}
return hash;
}
static bool verifyWrittenFile(FILESYSTEM* fs, const char* path,
size_t expected_size,
uint32_t expected_hash) {
File file = openRead(fs, path);
if (!file || file.size() != expected_size) {
if (file) file.close();
return false;
}
uint32_t hash = 2166136261UL;
uint8_t buffer[64];
size_t remaining = expected_size;
while (remaining > 0) {
size_t amount = remaining < sizeof(buffer) ? remaining : sizeof(buffer);
if (file.read(buffer, amount) != amount) {
file.close();
return false;
}
hash = updateFileHash(hash, buffer, amount);
remaining -= amount;
}
file.close();
return hash == expected_hash;
}
} // namespace
FloodRuleEngine::FloodRuleEngine() : _fs(NULL), _regions(NULL) {
@@ -369,24 +404,22 @@ void FloodRuleEngine::seedDefaults() {
}
void FloodRuleEngine::load() {
memset(_entries, 0, sizeof(_entries));
if (_fs == NULL) {
seedDefaults();
return;
}
if (!_fs->exists(RULE_FILE)) {
seedDefaults();
save();
return;
}
File file = openRead(_fs, RULE_FILE);
if (!file) return;
enum class FileState : uint8_t { Missing, Valid, Invalid, Unreadable };
auto loadFile = [this](const char* path) -> FileState {
memset(_entries, 0, sizeof(_entries));
if (!_fs->exists(path)) return FileState::Missing;
File file = openRead(_fs, path);
if (!file) return FileState::Unreadable;
Entry* loaded = _entries;
auto readExact = [&file](void* dest, size_t len) {
return file.read((uint8_t*)dest, len) == len;
};
Entry* loaded = _entries;
auto readExact = [&file](void* dest, size_t len) {
return file.read((uint8_t*)dest, len) == len;
};
uint8_t magic[4];
uint8_t count = 0;
@@ -557,19 +590,72 @@ void FloodRuleEngine::load() {
success = false;
}
}
file.close();
file.close();
if (!success) memset(_entries, 0, sizeof(_entries));
return success ? FileState::Valid : FileState::Invalid;
};
// Invalid or truncated persistence fails open. Corrupt bytes must never
// enable a forwarding block.
if (!success) memset(_entries, 0, sizeof(_entries));
FileState primary = loadFile(RULE_FILE);
if (primary == FileState::Valid) {
if (_fs->exists(RULE_TEMP_FILE)) _fs->remove(RULE_TEMP_FILE);
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
return;
}
if (primary == FileState::Unreadable) {
return;
}
FileState temp = loadFile(RULE_TEMP_FILE);
if (temp == FileState::Valid) {
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (!_fs->exists(RULE_FILE)
&& _fs->rename(RULE_TEMP_FILE, RULE_FILE)) {
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
}
}
return;
}
FileState backup = loadFile(RULE_BACKUP_FILE);
if (backup == FileState::Valid) {
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (!_fs->exists(RULE_FILE)
&& _fs->rename(RULE_BACKUP_FILE, RULE_FILE)
&& temp != FileState::Unreadable
&& _fs->exists(RULE_TEMP_FILE)) {
_fs->remove(RULE_TEMP_FILE);
}
}
return;
}
seedDefaults();
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (temp == FileState::Invalid) _fs->remove(RULE_TEMP_FILE);
if (backup == FileState::Invalid) _fs->remove(RULE_BACKUP_FILE);
if (primary != FileState::Unreadable && temp != FileState::Unreadable
&& backup != FileState::Unreadable) {
save();
}
}
bool FloodRuleEngine::save() {
if (_fs == NULL) return false;
File file = openWrite(_fs, RULE_FILE);
if (_fs->exists(RULE_TEMP_FILE) || _fs->exists(RULE_BACKUP_FILE))
return false;
File file = openWrite(_fs, RULE_TEMP_FILE);
if (!file) return false;
auto writeExact = [&file](const void* src, size_t len) {
return file.write((const uint8_t*)src, len) == len;
size_t bytes_written = 0;
uint32_t write_hash = 2166136261UL;
auto writeExact = [&file, &bytes_written, &write_hash](
const void* src, size_t len) {
const uint8_t* data = (const uint8_t*)src;
if (file.write(data, len) != len) return false;
bytes_written += len;
write_hash = updateFileHash(write_hash, data, len);
return true;
};
const uint8_t magic[4] = {'F', 'P', 'F', '7'};
@@ -621,7 +707,21 @@ bool FloodRuleEngine::save() {
&& writeExact(&stop_on_match, sizeof(stop_on_match));
}
file.close();
return success;
if (!success || !verifyWrittenFile(
_fs, RULE_TEMP_FILE, bytes_written, write_hash)) {
_fs->remove(RULE_TEMP_FILE);
return false;
}
if (_fs->exists(RULE_FILE)
&& !_fs->rename(RULE_FILE, RULE_BACKUP_FILE)) {
_fs->remove(RULE_TEMP_FILE);
return false;
}
if (!_fs->rename(RULE_TEMP_FILE, RULE_FILE)) {
return false;
}
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
return true;
}
bool FloodRuleEngine::fieldsMatch(
@@ -699,12 +799,37 @@ int FloodRuleEngine::nextMatch(uint32_t match_mask,
match_mask, visited_mask, priorities, RULE_SLOTS);
}
uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) const {
bool FloodRuleEngine::resolveTargetRegion(
const char* name, TransportKey& scope,
const char*& canonical_name) {
if (_regions == NULL || name == NULL || name[0] == 0) return false;
RegionEntry* region = _regions->findByName(name);
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| _regions->getTransportKeysFor(*region, &scope, 1) <= 0
|| scope.isNull()) {
return false;
}
canonical_name = region->name;
return true;
}
uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) {
uint8_t priorities[RULE_SLOTS];
uint8_t stop_flags[RULE_SLOTS];
for (int i = 0; i < RULE_SLOTS; i++) {
priorities[i] = _entries[i].priority;
stop_flags[i] = _entries[i].stop_on_match ? 1 : 0;
const Entry& entry = _entries[i];
bool region_usable = true;
if (entry.target_region_name[0] != 0) {
TransportKey scope;
const char* canonical_name = NULL;
region_usable = resolveTargetRegion(
entry.target_region_name, scope, canonical_name);
}
stop_flags[i] = FloodFilterPolicy::stopActionApplies(
entry.stop_on_match, entry.target_region_name[0] != 0,
region_usable) ? 1 : 0;
}
return FloodFilterPolicy::truncateRulesAtStop(
match_mask, priorities, stop_flags, RULE_SLOTS);
@@ -713,7 +838,7 @@ uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) const {
uint32_t FloodRuleEngine::evaluate(
const mesh::Packet* packet, bool temp_radio_active,
bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
const RegionEntry* incoming_region) {
static_assert(RULE_SLOTS <= 32,
"flood rule match mask supports at most 32 slots");
if (packet == NULL || !packet->isRouteFlood()) return 0;
@@ -777,15 +902,10 @@ bool FloodRuleEngine::applyScope(mesh::Packet* packet, uint32_t match_mask,
if (entry.scope_name[0] != 0) {
deriveScopeKey(entry.scope_name, scope);
} else {
RegionEntry* region = _regions == NULL ? NULL
: _regions->findByName(entry.target_region_name);
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| _regions->getTransportKeysFor(*region, &scope, 1) <= 0
|| scope.isNull()) {
if (!resolveTargetRegion(
entry.target_region_name, scope, target_name)) {
continue;
}
target_name = region->name;
}
bool changed = FloodFilterPolicy::setTransportScope(
@@ -24,7 +24,7 @@ public:
uint32_t evaluate(const mesh::Packet* packet, bool temp_radio_active,
bool incoming_region_allowed,
const RegionEntry* incoming_region) const;
const RegionEntry* incoming_region);
bool applyScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change = true);
@@ -80,7 +80,9 @@ private:
bool authenticateChannel(const Entry& entry,
const mesh::Packet* packet) const;
int nextMatch(uint32_t match_mask, uint32_t visited_mask) const;
uint32_t applyStop(uint32_t match_mask) const;
bool resolveTargetRegion(const char* name, TransportKey& scope,
const char*& canonical_name);
uint32_t applyStop(uint32_t match_mask);
void format(const char* args, char* reply) const;
void formatDetail(int index, char* reply, size_t reply_len) const;
void set(const char* args, char* reply,
+41 -6
View File
@@ -740,7 +740,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
const char* text = (const char*)&data[5];
const size_t text_len = strlen(text);
uint8_t temp[166];
uint8_t temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT + 1];
temp[5] = 0;
bool send_ack = false;
@@ -776,17 +776,52 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
send_ack = true;
}
} else { // TXT_TYPE_CLI_DATA
if (sender_timestamp < client->last_timestamp) {
uint32_t request_id = sender_timestamp;
mesh::RemoteCliRequest::parse(data, len, 5, request_id);
const uint32_t command_fingerprint =
mesh::RemoteCliReplyCache::fingerprint(text, text_len);
const char* cached_response = NULL;
const bool cached_retry = remote_cli_reply_cache.lookup(
client->id.pub_key, request_id, command_fingerprint,
&cached_response);
if (sender_timestamp < client->last_timestamp && !cached_retry) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected");
return;
}
const bool is_retry = sender_timestamp == client->last_timestamp;
client->last_timestamp = sender_timestamp;
if (client->isAdmin() && !is_retry) {
const bool repeated_timestamp = sender_timestamp == client->last_timestamp;
if (sender_timestamp > client->last_timestamp) {
client->last_timestamp = sender_timestamp;
}
if (cached_retry) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply");
size_t cached_len = strlen(cached_response);
if (cached_len > mesh::RemoteCliReplyCache::MAX_REPLY_TEXT) {
cached_len = mesh::RemoteCliReplyCache::MAX_REPLY_TEXT;
}
memcpy(&temp[5], cached_response, cached_len);
temp[5 + cached_len] = 0;
temp[4] = (TXT_TYPE_CLI_DATA << 2);
} else if (repeated_timestamp) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply");
return;
} else if (client->isAdmin()) {
handleCommand(sender_timestamp, (char*)text, (char*)&temp[5],
i, packet->getPathHashSize());
temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN)
temp[5 + mesh::RemoteCliReplyCache::MAX_REPLY_TEXT] = 0;
if (temp[5] == 0) strcpy((char*)&temp[5], "OK");
remote_cli_reply_cache.remember(client->id.pub_key, request_id,
command_fingerprint,
(char*)&temp[5]);
temp[4] = (TXT_TYPE_CLI_DATA << 2);
} else {
const char* error = "Err - admin permission required";
strcpy((char*)&temp[5], error);
remote_cli_reply_cache.remember(client->id.pub_key, request_id,
command_fingerprint, error);
temp[4] = (TXT_TYPE_CLI_DATA << 2);
}
// CLI_DATA replies are the result signal; no separate ACK is expected.
}
+3
View File
@@ -25,6 +25,8 @@
#include <helpers/StatsFormatHelper.h>
#include <helpers/ClientACL.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
#include <helpers/RegionMap.h>
#include "FloodRuleEngine.h"
#include <RTClib.h>
@@ -141,6 +143,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#endif
ClientACL acl;
mesh::LogicalMessageCache<ROOM_MESSAGE_CACHE_SIZE> recent_room_posts;
mesh::RemoteCliReplyCache remote_cli_reply_cache;
CommonCLI _cli;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
UserGpioReplyTracker _gpio_reply_tracker;
+12
View File
@@ -92,6 +92,18 @@ test("builds and parses the BlackHole86 policy definition", () => {
assert.strictEqual(tool.buildDefinition(rule), definition);
});
test("treats channel star as no channel condition", () => {
const rule = tool.parseDefinition(
"policy set everything phase=forward owner=filter priority=1 when route=flood type=any hops=all channel=* do drop"
);
assert.strictEqual(rule.channel, "");
assert.strictEqual(tool.matchRule(rule, packet({ type: "grp_data" })).matched, true);
assert.strictEqual(tool.matchRule(
rule, packet({ type: "ota", channel: "" })
).matched, true);
assert.doesNotMatch(tool.buildDefinition(rule), /channel=/);
});
test("keeps a payload class and path bucket in one rule", () => {
const definition = "policy set other-bucket phase=rewrite owner=scope priority=130 when route=flood type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow";
const rule = tool.parseDefinition(definition);
+17 -4
View File
@@ -1,4 +1,5 @@
#include <helpers/BaseChatMesh.h>
#include <helpers/RemoteCliRequest.h>
#include <Utils.h>
#ifndef SERVER_RESPONSE_DELAY
@@ -486,16 +487,28 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
return rc;
}
int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& est_timeout) {
int BaseChatMesh::sendCommandData(const ContactInfo& recipient,
uint32_t timestamp, uint8_t attempt,
const char* text, uint32_t& est_timeout,
uint32_t logical_request_id) {
int text_len = strlen(text);
if (text_len > MAX_TEXT_LEN) return MSG_SEND_FAILED;
uint8_t temp[5+MAX_TEXT_LEN+1];
uint8_t temp[5 + MAX_TEXT_LEN + mesh::RemoteCliRequest::EXTENSION_SIZE];
memcpy(temp, &timestamp, 4); // mostly an extra blob to help make packet_hash unique
temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2);
memcpy(&temp[5], text, text_len + 1);
memcpy(&temp[5], text, text_len);
auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, 5 + text_len);
size_t payload_len = 5 + text_len;
if (logical_request_id != 0) {
payload_len = mesh::RemoteCliRequest::append(
temp, sizeof(temp), 5, text_len, logical_request_id);
if (payload_len == 0) return MSG_SEND_FAILED;
}
auto pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id,
recipient.getSharedSecret(self_id), temp,
payload_len);
if (pkt == NULL) return MSG_SEND_FAILED;
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
+4 -1
View File
@@ -163,7 +163,10 @@ public:
int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text,
uint32_t& expected_ack, uint32_t& est_timeout, uint8_t* packet_hash = NULL,
const uint8_t* replace_retry_key = NULL);
int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& est_timeout);
int sendCommandData(const ContactInfo& recipient, uint32_t timestamp,
uint8_t attempt, const char* text,
uint32_t& est_timeout,
uint32_t logical_request_id = 0);
bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len);
bool sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, uint16_t data_type, const uint8_t* data, int data_len);
int sendLogin(const ContactInfo& recipient, const char* password, uint32_t& est_timeout);
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace mesh {
// A command reply should normally clear the BLE stack within a few connection
// intervals. Allow generous transient backpressure, but do not leave the app
// waiting forever on a link whose notification path has stopped making
// progress.
static const uint32_t BLE_TX_STALL_TIMEOUT_MS = 10000UL;
static const uint32_t BLE_DISCONNECT_RETRY_INTERVAL_MS = 1000UL;
inline bool bleElapsedAtLeast(uint32_t now, uint32_t since,
uint32_t interval_ms) {
// Unsigned subtraction remains correct across one millis() rollover.
return static_cast<uint32_t>(now - since) >= interval_ms;
}
// Submit at most one ATT notification per writer call. This preserves the
// exact number of bytes accepted before a failure; some BLE UART wrappers
// collapse an internally partial multi-notification write to a zero return.
template <typename WriteChunk>
size_t writeBleFrameInChunks(const uint8_t* frame, size_t len,
size_t max_payload, WriteChunk write_chunk) {
if (frame == nullptr || len == 0 || max_payload == 0) return 0;
size_t total_written = 0;
while (total_written < len) {
const size_t remaining = len - total_written;
const size_t chunk_len = remaining < max_payload ? remaining : max_payload;
size_t chunk_written = write_chunk(frame + total_written, chunk_len);
if (chunk_written > chunk_len) chunk_written = chunk_len;
total_written += chunk_written;
if (chunk_written != chunk_len) break;
}
return total_written;
}
class BleTxStallWatchdog {
bool _active;
uint32_t _blocked_since;
public:
BleTxStallWatchdog() : _active(false), _blocked_since(0) {}
void reset() {
_active = false;
_blocked_since = 0;
}
bool noteBlocked(uint32_t now,
uint32_t timeout_ms = BLE_TX_STALL_TIMEOUT_MS) {
if (!_active) {
_active = true;
_blocked_since = now;
return timeout_ms == 0;
}
return bleElapsedAtLeast(now, _blocked_since, timeout_ms);
}
bool active() const { return _active; }
};
// Disconnect requests are asynchronous and can transiently fail. Keep the
// transport in a recovery state until its disconnect callback arrives, and
// periodically retry the request instead of abandoning a live controller link
// after one failed call.
class BleDisconnectRecovery {
bool _pending;
bool _attempted;
uint32_t _last_attempt;
public:
BleDisconnectRecovery()
: _pending(false), _attempted(false), _last_attempt(0) {}
void begin() {
_pending = true;
_attempted = false;
_last_attempt = 0;
}
void complete() {
_pending = false;
_attempted = false;
_last_attempt = 0;
}
bool pending() const { return _pending; }
bool shouldAttempt(
uint32_t now,
uint32_t retry_interval_ms = BLE_DISCONNECT_RETRY_INTERVAL_MS) {
if (!_pending) return false;
if (!_attempted ||
bleElapsedAtLeast(now, _last_attempt, retry_interval_ms)) {
_attempted = true;
_last_attempt = now;
return true;
}
return false;
}
};
} // namespace mesh
+86 -38
View File
@@ -10,15 +10,47 @@ inline bool isCompanionPushFrame(const uint8_t* frame, size_t len) {
return frame != NULL && len > 0 && (frame[0] & 0x80) != 0;
}
enum CompanionFrameClass : uint8_t {
COMPANION_RESPONSE = 0,
COMPANION_REQUIRED_PUSH = 1,
COMPANION_BEST_EFFORT_PUSH = 2,
};
/**
* Companion push codes share one numeric range, but they do not share one
* delivery contract. Command completions, login results, MSG_WAITING, and
* status/telemetry replies are required for the app to make progress. Only
* unsolicited discovery/path updates and packet logs are safe to shed.
*
* Unknown future push codes default to required so an older transport does
* not silently discard a new protocol result it does not yet recognize.
*/
inline CompanionFrameClass companionFrameClass(const uint8_t* frame,
size_t len) {
if (!isCompanionPushFrame(frame, len)) return COMPANION_RESPONSE;
switch (frame[0]) {
case 0x80: // ADVERT
case 0x81: // PATH_UPDATED
case 0x84: // RAW_DATA
case 0x88: // LOG_RX_DATA
case 0x8A: // NEW_ADVERT
return COMPANION_BEST_EFFORT_PUSH;
default:
return COMPANION_REQUIRED_PUSH;
}
}
inline bool companionFrameRequiresDelivery(const uint8_t* frame, size_t len) {
return companionFrameClass(frame, len) != COMPANION_BEST_EFFORT_PUSH;
}
/**
* Add a frame to a companion transport's contiguous outbound queue.
*
* Protocol responses use codes below 0x80 and complete an app command that is
* waiting for them. Push frames use codes at or above 0x80 and are
* asynchronous. Keep one slot available for a response, and if an older queue
* is already full of mixed traffic, let a response replace the newest push.
* Responses are inserted before pushes so packet-log traffic cannot delay a
* command indefinitely.
* Protocol responses use codes below 0x80. Push frames use codes at or above
* 0x80, but some pushes also complete an app operation. Keep one slot away
* from best-effort traffic, let required frames displace best-effort traffic,
* and order responses before required pushes before best-effort pushes.
*/
template <typename Frame, typename QueueLength>
bool enqueueCompanionFrame(Frame queue[], QueueLength& queue_len, size_t capacity,
@@ -28,20 +60,38 @@ bool enqueueCompanionFrame(Frame queue[], QueueLength& queue_len, size_t capacit
size_t count = static_cast<size_t>(queue_len);
if (count > capacity) return false;
const bool push = isCompanionPushFrame(src, len);
if (push && count >= capacity - 1) {
return false; // preserve one slot for the reply to an app command
const CompanionFrameClass incoming_class = companionFrameClass(src, len);
// MSG_WAITING is a level-triggered tickle: one queued copy is enough to tell
// the app to fetch all pending messages. Coalescing it preserves space for
// command completions without losing information.
if (src[0] == 0x83) {
for (size_t i = 0; i < count; ++i) {
if (queue[i].len > 0 && queue[i].buf[0] == 0x83) return true;
}
}
if (incoming_class == COMPANION_BEST_EFFORT_PUSH
&& count >= capacity - 1) {
return false; // preserve one slot for delivery-required traffic
}
if (count == capacity) {
// A response may displace best-effort asynchronous traffic, but never an
// earlier response that another command is already waiting for.
// A delivery-required frame may displace best-effort asynchronous traffic,
// but never another delivery-required frame.
if (incoming_class == COMPANION_BEST_EFFORT_PUSH) return false;
size_t evict = count;
while (evict > 0) {
--evict;
if (isCompanionPushFrame(queue[evict].buf, queue[evict].len)) break;
if (companionFrameClass(queue[evict].buf, queue[evict].len)
== COMPANION_BEST_EFFORT_PUSH) {
break;
}
}
if (companionFrameClass(queue[evict].buf, queue[evict].len)
!= COMPANION_BEST_EFFORT_PUSH) {
return false;
}
if (!isCompanionPushFrame(queue[evict].buf, queue[evict].len)) return false;
for (size_t i = evict; i + 1 < count; ++i) {
queue[i] = queue[i + 1];
@@ -49,34 +99,32 @@ bool enqueueCompanionFrame(Frame queue[], QueueLength& queue_len, size_t capacit
--count;
}
size_t insert_at = count;
if (!push) {
// Keep responses FIFO with respect to one another, ahead of asynchronous
// pushes such as raw-packet logs and message-waiting notifications.
// The stable partition also repairs a queue populated by older admission
// behavior before this policy gets a chance to add the next response.
for (size_t i = 1; i < count; ++i) {
if (isCompanionPushFrame(queue[i].buf, queue[i].len)) continue;
// Stable insertion sort also repairs a queue populated by older admission
// behavior before the next frame is added.
for (size_t i = 1; i < count; ++i) {
Frame current = queue[i];
CompanionFrameClass current_class =
companionFrameClass(current.buf, current.len);
size_t j = i;
while (j > 0
&& companionFrameClass(queue[j - 1].buf, queue[j - 1].len)
> current_class) {
queue[j] = queue[j - 1];
--j;
}
queue[j] = current;
}
Frame response = queue[i];
size_t j = i;
while (j > 0
&& isCompanionPushFrame(queue[j - 1].buf, queue[j - 1].len)) {
queue[j] = queue[j - 1];
--j;
}
queue[j] = response;
}
for (size_t i = 0; i < count; ++i) {
if (isCompanionPushFrame(queue[i].buf, queue[i].len)) {
insert_at = i;
break;
}
}
for (size_t i = count; i > insert_at; --i) {
queue[i] = queue[i - 1];
size_t insert_at = count;
for (size_t i = 0; i < count; ++i) {
if (companionFrameClass(queue[i].buf, queue[i].len) > incoming_class) {
insert_at = i;
break;
}
}
for (size_t i = count; i > insert_at; --i) {
queue[i] = queue[i - 1];
}
queue[insert_at].len = len;
memcpy(queue[insert_at].buf, src, len);
+18 -2
View File
@@ -16,19 +16,22 @@ struct DeferredCliCommand {
bool pending;
int client_index;
uint32_t sender_timestamp;
uint32_t request_id;
uint8_t path_hash_size;
uint8_t secret[PUB_KEY_SIZE];
char command[MAX_PACKET_PAYLOAD + 1];
DeferredCliCommand()
: pending(false), client_index(-1), sender_timestamp(0), path_hash_size(1) {
: pending(false), client_index(-1), sender_timestamp(0), request_id(0),
path_hash_size(1) {
memset(secret, 0, sizeof(secret));
command[0] = 0;
}
bool enqueue(int new_client_index, uint32_t new_sender_timestamp,
uint8_t new_path_hash_size, const uint8_t* new_secret,
const char* new_command, size_t command_len) {
const char* new_command, size_t command_len,
uint32_t new_request_id = 0) {
if (pending || new_secret == NULL || new_command == NULL
|| command_len >= sizeof(command)) {
return false;
@@ -36,6 +39,8 @@ struct DeferredCliCommand {
client_index = new_client_index;
sender_timestamp = new_sender_timestamp;
request_id = new_request_id != 0
? new_request_id : new_sender_timestamp;
path_hash_size = new_path_hash_size;
memcpy(secret, new_secret, sizeof(secret));
memcpy(command, new_command, command_len);
@@ -44,10 +49,21 @@ struct DeferredCliCommand {
return true;
}
bool matches(int other_client_index, uint32_t other_request_id,
const char* other_command, size_t other_command_len) const {
return pending && other_command != NULL
&& client_index == other_client_index
&& request_id == other_request_id
&& other_command_len < sizeof(command)
&& command[other_command_len] == 0
&& memcmp(command, other_command, other_command_len) == 0;
}
void clear() {
pending = false;
client_index = -1;
sender_timestamp = 0;
request_id = 0;
path_hash_size = 1;
memset(secret, 0, sizeof(secret));
memset(command, 0, sizeof(command));
+9
View File
@@ -315,6 +315,15 @@ inline uint32_t truncateRulesAtStop(uint32_t match_mask,
return effective;
}
inline bool stopActionApplies(bool stop_on_match, bool has_region_target,
bool region_target_usable) {
// A direct scope= target is derived from its public name and is always
// usable. A region= target is configuration-backed; if it disappeared or
// can no longer carry floods, its rewrite and its terminal stop are inert.
return stop_on_match
&& (!has_region_target || region_target_usable);
}
inline bool scopeRuleAllowed(bool requires_region_match,
bool incoming_region_allowed) {
return !requires_region_match || incoming_region_allowed;
+78 -28
View File
@@ -7,11 +7,8 @@
namespace mesh {
// Keeps the most recently completed remote CLI reply so an exact request
// retry can recover a lost response without executing the command twice.
// This is deliberately a single bounded entry: remote CLI commands are
// serialized, and repeater builds on small MCUs cannot afford one reply-sized
// buffer for every ACL client.
// Keeps a small bounded history of completed remote CLI replies so a delayed
// exact retry can recover a lost response without executing the command twice.
class RemoteCliReplyCache {
public:
static constexpr size_t MAX_REPLY_TEXT =
@@ -19,6 +16,12 @@ public:
RemoteCliReplyCache() { clear(); }
#if defined(STM32_PLATFORM)
static constexpr size_t ENTRY_COUNT = 2;
#else
static constexpr size_t ENTRY_COUNT = 4;
#endif
static uint32_t fingerprint(const char* command, size_t command_len) {
if (command == NULL) return 0;
@@ -36,46 +39,93 @@ public:
uint32_t command_fingerprint, const char* response) {
if (sender_pub_key == NULL || response == NULL) return false;
memcpy(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_));
request_timestamp_ = request_timestamp;
command_fingerprint_ = command_fingerprint;
Entry* target = NULL;
for (size_t i = 0; i < ENTRY_COUNT; ++i) {
if (entryMatches(entries_[i], sender_pub_key, request_timestamp,
command_fingerprint)) {
target = &entries_[i];
latest_entry_ = (uint8_t)i;
break;
}
}
if (target == NULL) {
target = &entries_[next_entry_];
latest_entry_ = next_entry_;
next_entry_ = (uint8_t)((next_entry_ + 1) % ENTRY_COUNT);
}
memcpy(target->sender_pub_key, sender_pub_key,
sizeof(target->sender_pub_key));
target->request_timestamp = request_timestamp;
target->command_fingerprint = command_fingerprint;
size_t response_len = 0;
while (response_len < MAX_REPLY_TEXT && response[response_len] != 0) {
response_len++;
}
memcpy(response_, response, response_len);
response_[response_len] = 0;
valid_ = true;
memcpy(target->response, response, response_len);
target->response[response_len] = 0;
target->valid = true;
return true;
}
bool lookup(const uint8_t* sender_pub_key, uint32_t request_timestamp,
uint32_t command_fingerprint,
const char** response = NULL) const {
for (size_t i = 0; i < ENTRY_COUNT; ++i) {
if (!entryMatches(entries_[i], sender_pub_key, request_timestamp,
command_fingerprint)) {
continue;
}
if (response != NULL) *response = entries_[i].response;
return true;
}
return false;
}
bool matches(const uint8_t* sender_pub_key, uint32_t request_timestamp,
uint32_t command_fingerprint) const {
return valid_ && sender_pub_key != NULL
&& request_timestamp_ == request_timestamp
&& command_fingerprint_ == command_fingerprint
&& memcmp(sender_pub_key_, sender_pub_key, sizeof(sender_pub_key_)) == 0;
return lookup(sender_pub_key, request_timestamp, command_fingerprint);
}
const char* response() const { return response_; }
bool hasResponse() const { return valid_ && response_[0] != 0; }
bool isValid() const { return valid_; }
const char* response() const {
return latest_entry_ < ENTRY_COUNT && entries_[latest_entry_].valid
? entries_[latest_entry_].response : "";
}
bool hasResponse() const { return isValid() && response()[0] != 0; }
bool isValid() const {
return latest_entry_ < ENTRY_COUNT && entries_[latest_entry_].valid;
}
void clear() {
valid_ = false;
memset(sender_pub_key_, 0, sizeof(sender_pub_key_));
request_timestamp_ = 0;
command_fingerprint_ = 0;
memset(response_, 0, sizeof(response_));
memset(entries_, 0, sizeof(entries_));
next_entry_ = 0;
latest_entry_ = (uint8_t)ENTRY_COUNT;
}
private:
bool valid_;
uint8_t sender_pub_key_[PUB_KEY_SIZE];
uint32_t request_timestamp_;
uint32_t command_fingerprint_;
char response_[MAX_REPLY_TEXT + 1];
struct Entry {
bool valid;
uint8_t sender_pub_key[PUB_KEY_SIZE];
uint32_t request_timestamp;
uint32_t command_fingerprint;
char response[MAX_REPLY_TEXT + 1];
};
static bool entryMatches(const Entry& entry,
const uint8_t* sender_pub_key,
uint32_t request_timestamp,
uint32_t command_fingerprint) {
return entry.valid && sender_pub_key != NULL
&& entry.request_timestamp == request_timestamp
&& entry.command_fingerprint == command_fingerprint
&& memcmp(entry.sender_pub_key, sender_pub_key,
sizeof(entry.sender_pub_key)) == 0;
}
Entry entries_[ENTRY_COUNT];
uint8_t next_entry_;
uint8_t latest_entry_;
};
} // namespace mesh
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
// Backward-compatible logical request identity for remote CLI retries. The
// on-air timestamp remains fresh for legacy replay guards. New servers find
// this authenticated extension after the command's NUL; old servers stop at
// that NUL and continue to see the original command text.
class RemoteCliRequest {
public:
static constexpr size_t EXTENSION_SIZE = 9; // NUL + "MCR1" + uint32 id
static size_t append(uint8_t* payload, size_t capacity,
size_t command_offset, size_t command_len,
uint32_t logical_id) {
if (payload == NULL || logical_id == 0
|| command_offset + command_len + EXTENSION_SIZE > capacity) {
return 0;
}
size_t pos = command_offset + command_len;
payload[pos++] = 0;
payload[pos++] = 'M';
payload[pos++] = 'C';
payload[pos++] = 'R';
payload[pos++] = '1';
memcpy(payload + pos, &logical_id, sizeof(logical_id));
return pos + sizeof(logical_id);
}
static bool parse(const uint8_t* payload, size_t payload_len,
size_t command_offset, uint32_t& logical_id) {
if (payload == NULL || command_offset >= payload_len) return false;
const uint8_t* terminator = (const uint8_t*)memchr(
payload + command_offset, 0, payload_len - command_offset);
if (terminator == NULL) return false;
size_t pos = (size_t)(terminator - payload) + 1;
if (pos + 8 > payload_len
|| payload[pos] != 'M' || payload[pos + 1] != 'C'
|| payload[pos + 2] != 'R' || payload[pos + 3] != '1') {
return false;
}
memcpy(&logical_id, payload + pos + 4, sizeof(logical_id));
return logical_id != 0;
}
};
} // namespace mesh
+112 -13
View File
@@ -109,7 +109,7 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
deviceConnected = false;
pServer->disconnect(pServer->getConnId());
adv_restart_time = millis() + ADVERT_RESTART_DELAY;
scheduleAdvertisingRestart((uint32_t)millis());
}
}
@@ -122,7 +122,14 @@ void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t
BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id));
last_conn_id = param->connect.conn_id;
deviceConnected = false; // becomes usable only after authentication completes
oldDeviceConnected = false;
notifySucceeded = false;
// BLE callbacks run outside the Arduino loop. FreeRTOS owns the RX queue,
// so it is safe to reset here; defer the plain-array TX queue reset to the
// loop to avoid racing a notification completion.
xQueueReset(recv_queue);
_tx_reset_pending.store(true, std::memory_order_release);
_adv_restart_pending = false;
if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false);
}
@@ -134,15 +141,22 @@ void SerialBLEInterface::onDisconnect(BLEServer* pServer) {
BLE_DEBUG_PRINTLN("onDisconnect()");
deviceConnected = false;
notifySucceeded = false;
xQueueReset(recv_queue);
_tx_reset_pending.store(true, std::memory_order_release);
if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false);
if (_isEnabled) {
adv_restart_time = millis() + ADVERT_RESTART_DELAY;
scheduleAdvertisingRestart((uint32_t)millis());
}
}
// -------- BLECharacteristicCallbacks methods
void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) {
if (_tx_disconnect_recovery.pending()) {
BLE_DEBUG_PRINTLN("onWrite(): dropping frame while BLE reconnect is pending");
return;
}
uint8_t* rxValue = pCharacteristic->getData();
int len = pCharacteristic->getLength();
@@ -173,6 +187,58 @@ void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status sta
void SerialBLEInterface::clearBuffers() {
xQueueReset(recv_queue);
send_queue_len = 0;
notifySucceeded = false;
_tx_stall_watchdog.reset();
_tx_disconnect_recovery.complete();
_tx_reset_pending.store(false, std::memory_order_release);
}
void SerialBLEInterface::servicePendingTxReset() {
if (!_tx_reset_pending.exchange(false, std::memory_order_acq_rel)) return;
send_queue_len = 0;
notifySucceeded = false;
_tx_stall_watchdog.reset();
_tx_disconnect_recovery.complete();
}
void SerialBLEInterface::scheduleAdvertisingRestart(uint32_t now) {
_adv_restart_started = now;
_adv_restart_pending = true;
}
void SerialBLEInterface::serviceTxRecovery(uint32_t now) {
if (!_tx_disconnect_recovery.pending()) return;
if (pServer == NULL || pServer->getConnectedCount() == 0) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX link is already closed");
deviceConnected = false;
clearBuffers();
if (_isEnabled) scheduleAdvertisingRestart(now);
return;
}
if (!_tx_disconnect_recovery.shouldAttempt(now)) return;
// BLEServer::disconnect() does not expose the controller return code. Keep
// recovery pending and issue another bounded request until onDisconnect() or
// getConnectedCount() confirms that the link actually closed.
pServer->disconnect(last_conn_id);
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX disconnect requested");
}
void SerialBLEInterface::recoverStalledTx(const char* cause) {
if (_tx_disconnect_recovery.pending()) return;
BLE_DEBUG_PRINTLN("SerialBLEInterface: %s; forcing reconnect", cause);
// Preserve the controller's physical state until the disconnect callback,
// while the recovery state makes isConnected() false to callers.
xQueueReset(recv_queue);
notifySucceeded = false;
send_queue_len = 0;
_tx_stall_watchdog.reset();
_tx_disconnect_recovery.begin();
serviceTxRecovery((uint32_t)millis());
}
void SerialBLEInterface::enable() {
@@ -190,7 +256,7 @@ void SerialBLEInterface::enable() {
//pServer->getAdvertising()->setMaxInterval(1000);
pServer->getAdvertising()->start();
adv_restart_time = 0;
_adv_restart_pending = false;
}
void SerialBLEInterface::disable() {
@@ -202,16 +268,18 @@ void SerialBLEInterface::disable() {
pServer->disconnect(last_conn_id);
pService->stop();
oldDeviceConnected = deviceConnected = false;
adv_restart_time = 0;
clearBuffers();
_adv_restart_pending = false;
}
size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
servicePendingTxReset();
if (len > MAX_FRAME_SIZE) {
BLE_DEBUG_PRINTLN("writeFrame(), frame too big, len=%d", len);
return 0;
}
if (deviceConnected && len > 0) {
if (isConnected() && len > 0) {
if (!mesh::enqueueCompanionFrame(send_queue, send_queue_len, FRAME_QUEUE_SIZE,
src, len)) {
BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!");
@@ -229,22 +297,33 @@ bool SerialBLEInterface::isReadBusy() const {
}
bool SerialBLEInterface::isWriteBusy() const {
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?
return !mesh::bleElapsedAtLeast((uint32_t)millis(), _last_write,
BLE_WRITE_MIN_INTERVAL);
}
size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
const uint32_t now = (uint32_t)millis();
servicePendingTxReset();
if (_tx_disconnect_recovery.pending()) {
serviceTxRecovery(now);
return 0;
}
if (send_queue_len > 0 // first, check send queue
&& millis() >= _last_write + BLE_WRITE_MIN_INTERVAL // space the writes apart
&& mesh::bleElapsedAtLeast(now, _last_write,
BLE_WRITE_MIN_INTERVAL) // space the writes apart
) {
const uint16_t peer_mtu = pServer->getPeerMTU(last_conn_id);
const bool notifications_ready = pTxDescriptor != NULL && pTxDescriptor->getNotifications();
const bool frame_fits = peer_mtu > 3 && send_queue[0].len <= peer_mtu - 3;
const bool delivery_required = mesh::companionFrameRequiresDelivery(
send_queue[0].buf, send_queue[0].len);
// A fresh pairing can deliver the app's first command before its CCCD
// subscription or MTU exchange completes. Keep the response queued until
// both are ready instead of silently dropping/truncating device info.
if (notifications_ready && frame_fits) {
_last_write = millis();
_last_write = now;
notifySucceeded = false;
pTxCharacteristic->setValue(send_queue[0].buf, send_queue[0].len);
pTxCharacteristic->notify();
@@ -256,8 +335,21 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
for (int i = 0; i < send_queue_len; i++) { // delete top item from queue
send_queue[i] = send_queue[i + 1];
}
_tx_stall_watchdog.reset();
} else if (delivery_required
&& _tx_stall_watchdog.noteBlocked(now)) {
recoverStalledTx("command reply notification blocked for 10 seconds");
return 0;
}
} else if (delivery_required
&& _tx_stall_watchdog.noteBlocked(now)) {
recoverStalledTx("command reply waiting for notifications or MTU for 10 seconds");
return 0;
} else if (!delivery_required) {
_tx_stall_watchdog.reset();
}
} else if (send_queue_len == 0) {
_tx_stall_watchdog.reset();
}
Frame frame;
@@ -276,28 +368,35 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
//pServer->getAdvertising()->setMinInterval(500);
//pServer->getAdvertising()->setMaxInterval(1000);
adv_restart_time = millis() + ADVERT_RESTART_DELAY;
scheduleAdvertisingRestart(now);
} else {
BLE_DEBUG_PRINTLN("SerialBLEInterface -> stopping advertising");
BLE_DEBUG_PRINTLN("SerialBLEInterface -> connecting...");
// connecting
// do stuff here on connecting
pServer->getAdvertising()->stop();
adv_restart_time = 0;
_adv_restart_pending = false;
}
oldDeviceConnected = deviceConnected;
}
if (adv_restart_time && millis() >= adv_restart_time) {
if (_adv_restart_pending &&
mesh::bleElapsedAtLeast(now, _adv_restart_started,
ADVERT_RESTART_DELAY)) {
if (pServer->getConnectedCount() == 0) {
BLE_DEBUG_PRINTLN("SerialBLEInterface -> re-starting advertising");
pServer->getAdvertising()->start(); // re-Start advertising
_adv_restart_pending = false;
} else {
// A disconnect can take longer than the normal restart delay. Keep the
// restart armed instead of losing it while the controller still reports
// the old connection.
_adv_restart_started = now;
}
adv_restart_time = 0;
}
return 0;
}
bool SerialBLEInterface::isConnected() const {
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
return !_tx_disconnect_recovery.pending() && deviceConnected;
}
+13 -3
View File
@@ -1,6 +1,7 @@
#pragma once
#include "../BaseSerialInterface.h"
#include "../BleTxStallWatchdog.h"
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
@@ -20,8 +21,12 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
bool _isEnabled;
uint16_t last_conn_id;
uint32_t _pin_code;
unsigned long _last_write;
unsigned long adv_restart_time;
uint32_t _last_write;
uint32_t _adv_restart_started;
bool _adv_restart_pending;
mesh::BleTxStallWatchdog _tx_stall_watchdog;
mesh::BleDisconnectRecovery _tx_disconnect_recovery;
std::atomic<bool> _tx_reset_pending{false};
std::atomic<bool> _pairingRequestPending{false};
struct Frame {
@@ -37,6 +42,10 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
Frame send_queue[FRAME_QUEUE_SIZE];
void clearBuffers();
void servicePendingTxReset();
void scheduleAdvertisingRestart(uint32_t now);
void recoverStalledTx(const char* cause);
void serviceTxRecovery(uint32_t now);
protected:
// BLESecurityCallbacks methods
@@ -65,7 +74,8 @@ public:
deviceConnected = false;
oldDeviceConnected = false;
notifySucceeded = false;
adv_restart_time = 0;
_adv_restart_started = 0;
_adv_restart_pending = false;
_isEnabled = false;
_last_write = 0;
last_conn_id = 0;
+113 -6
View File
@@ -277,6 +277,8 @@ void SerialBLEInterface::clearBuffers() {
send_queue_len = 0;
recv_queue_len = 0;
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
_tx_disconnect_recovery.complete();
bleuart.flush();
}
@@ -298,6 +300,80 @@ void SerialBLEInterface::shiftRecvQueueLeft() {
}
}
size_t SerialBLEInterface::writeBleUartFrame(const Frame& frame) {
BLEConnection* conn = Bluefruit.Connection(_conn_handle);
if (conn == nullptr || !conn->connected() ||
!bleuart.notifyEnabled(_conn_handle)) {
return 0;
}
const uint16_t mtu = conn->getMtu();
if (mtu <= 3) return 0;
// BLEUart::write() reports either the full requested length or zero, even
// when its internal multi-notification loop queued an earlier fragment
// before a later fragment failed. Submit one ATT payload at a time so a
// non-zero return accurately tells us that part of the protocol frame is
// already on the stream and must never be followed by a whole-frame retry.
return mesh::writeBleFrameInChunks(
frame.buf, frame.len, mtu - 3,
[this](const uint8_t* data, size_t len) {
return bleuart.write(_conn_handle, data, len);
});
}
void SerialBLEInterface::serviceTxRecovery(uint32_t now) {
if (!_tx_disconnect_recovery.pending()) return;
BLEConnection* conn = _conn_handle == BLE_CONN_HANDLE_INVALID
? nullptr
: Bluefruit.Connection(_conn_handle);
if (_conn_handle == BLE_CONN_HANDLE_INVALID || conn == nullptr ||
!conn->connected()) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX link is already closed");
_conn_handle = BLE_CONN_HANDLE_INVALID;
_isDeviceConnected = false;
_peer_address_valid = false;
_security_timer.cancel();
clearBuffers();
if (_isEnabled && !isAdvertising()) {
Bluefruit.Advertising.start(0);
}
return;
}
if (!_tx_disconnect_recovery.shouldAttempt(now)) return;
const uint32_t result = sd_ble_gap_disconnect(
_conn_handle, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION);
if (result == NRF_SUCCESS) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX disconnect requested");
} else if (result == NRF_ERROR_INVALID_STATE) {
BLE_DEBUG_PRINTLN("SerialBLEInterface: stalled TX disconnect already in progress");
} else {
BLE_DEBUG_PRINTLN(
"SerialBLEInterface: stalled TX disconnect failed, err=0x%08lX; will retry",
(unsigned long)result);
}
}
void SerialBLEInterface::recoverStalledTx(const char* cause) {
if (_tx_disconnect_recovery.pending()) return;
BLE_DEBUG_PRINTLN("SerialBLEInterface: %s; forcing reconnect", cause);
// Keep the physical connection state intact until the SoftDevice confirms
// disconnection, but make isConnected() false through the recovery state so
// no more companion frames enter this damaged stream.
send_queue_len = 0;
recv_queue_len = 0;
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
bleuart.flush();
_tx_disconnect_recovery.begin();
serviceTxRecovery((uint32_t)millis());
}
bool SerialBLEInterface::isValidConnection(uint16_t handle, bool requireWaitingForSecurity) const {
if (_conn_handle != handle) {
return false;
@@ -365,38 +441,68 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
}
size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
const uint32_t check_now = (uint32_t)millis();
if (_tx_disconnect_recovery.pending()) {
serviceTxRecovery(check_now);
return 0;
}
if (send_queue_len > 0) {
if (!isConnected()) {
BLE_DEBUG_PRINTLN("writeBytes: connection invalid, clearing send queue");
send_queue_len = 0;
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
} else {
unsigned long now = millis();
uint32_t now = check_now;
bool throttle_active = (_last_retry_attempt > 0 && (now - _last_retry_attempt) < BLE_RETRY_THROTTLE_MS);
if (!throttle_active) {
Frame frame_to_send = send_queue[0];
const bool delivery_required = mesh::companionFrameRequiresDelivery(
frame_to_send.buf, frame_to_send.len);
size_t written = bleuart.write(frame_to_send.buf, frame_to_send.len);
size_t written = writeBleUartFrame(frame_to_send);
if (written == frame_to_send.len) {
BLE_DEBUG_PRINTLN("writeBytes: sz=%u, hdr=%u", (unsigned)frame_to_send.len, (unsigned)frame_to_send.buf[0]);
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
shiftSendQueueLeft();
} else if (written > 0) {
BLE_DEBUG_PRINTLN("writeBytes: partial write, sent=%u of %u, dropping corrupted frame", (unsigned)written, (unsigned)frame_to_send.len);
_last_retry_attempt = 0;
shiftSendQueueLeft();
BLE_DEBUG_PRINTLN("writeBytes: partial write, sent=%u of %u",
(unsigned)written,
(unsigned)frame_to_send.len);
// The app cannot recover framing after receiving only part of one
// protocol frame. Reconnect instead of following it with another
// frame on the same BLE UART stream.
recoverStalledTx("partial BLE UART frame");
return 0;
} else {
if (!isConnected()) {
BLE_DEBUG_PRINTLN("writeBytes failed: connection lost, dropping frame");
_last_retry_attempt = 0;
_tx_stall_watchdog.reset();
shiftSendQueueLeft();
} else {
BLE_DEBUG_PRINTLN("writeBytes failed (buffer full), keeping frame for retry");
_last_retry_attempt = now;
if (delivery_required) {
if (_tx_stall_watchdog.noteBlocked((uint32_t)now)) {
recoverStalledTx("command reply blocked for 10 seconds");
return 0;
}
} else {
// Best-effort pushes do not make an otherwise healthy but idle
// app reconnect. A later response is inserted ahead of them and
// starts its own bounded watchdog window.
_tx_stall_watchdog.reset();
}
}
}
}
}
} else {
_tx_stall_watchdog.reset();
}
if (recv_queue_len > 0) {
@@ -478,7 +584,8 @@ void SerialBLEInterface::onBleUartRX(uint16_t conn_handle) {
}
bool SerialBLEInterface::isConnected() const {
return _isDeviceConnected && Bluefruit.connected() > 0;
return !_tx_disconnect_recovery.pending() && _isDeviceConnected &&
Bluefruit.connected() > 0;
}
bool SerialBLEInterface::isReadBusy() const {
+6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "../BaseSerialInterface.h"
#include "../BleTxStallWatchdog.h"
#include "SecuritySessionTimer.h"
#include <bluefruit.h>
@@ -20,6 +21,8 @@ class SerialBLEInterface : public BaseSerialInterface {
bool _peer_address_valid;
bool _bond_removed_for_connection;
SecuritySessionTimer _security_timer;
mesh::BleTxStallWatchdog _tx_stall_watchdog;
mesh::BleDisconnectRecovery _tx_disconnect_recovery;
struct Frame {
uint8_t len;
@@ -37,6 +40,9 @@ class SerialBLEInterface : public BaseSerialInterface {
void clearBuffers();
void shiftSendQueueLeft();
void shiftRecvQueueLeft();
size_t writeBleUartFrame(const Frame& frame);
void recoverStalledTx(const char* cause);
void serviceTxRecovery(uint32_t now);
bool removeStoredBondForPeer(const char* cause);
bool isValidConnection(uint16_t handle, bool requireWaitingForSecurity = false) const;
bool isAdvertising() const;
+49 -26
View File
@@ -13,38 +13,61 @@ void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right) {
sha256_trunc2(out, 4, left, 4, right, 4);
}
void MerkleAccumulator::reset() {
memset(_peaks, 0, sizeof(_peaks));
_valid_mask = 0;
_count = 0;
}
bool MerkleAccumulator::add(const uint8_t leaf[4]) {
if (leaf == nullptr || _count == UINT32_MAX) return false;
uint8_t current[4];
memcpy(current, leaf, 4);
uint8_t level = 0;
while (level < 32 && (_valid_mask & ((uint32_t)1U << level)) != 0) {
merkle_combine(current, _peaks[level], current);
_valid_mask &= ~((uint32_t)1U << level);
level++;
}
if (level >= 32) return false;
memcpy(_peaks[level], current, 4);
_valid_mask |= (uint32_t)1U << level;
_count++;
return true;
}
bool MerkleAccumulator::finish(uint8_t out[4]) const {
if (out == nullptr || _count == 0 || _valid_mask == 0) return false;
uint8_t level = 0;
while (level < 32
&& (_valid_mask & ((uint32_t)1U << level)) == 0) {
level++;
}
if (level >= 32) return false;
uint8_t accumulated[4];
memcpy(accumulated, _peaks[level], 4);
for (uint8_t next = level + 1; next < 32; ++next) {
if ((_valid_mask & ((uint32_t)1U << next)) != 0) {
merkle_combine(accumulated, _peaks[next], accumulated);
}
}
memcpy(out, accumulated, 4);
return true;
}
// Root via binary-counter / Merkle-Mountain-Range with right-to-left bagging.
// Equivalent to the level-by-level "pair adjacent, promote lone last (left||right)" reduction
// (verified against the reference implementation across many counts in the native tests).
void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count) {
if (count == 0) { memset(out, 0, 4); return; }
if (count == 1) { memcpy(out, leaves, 4); return; }
uint8_t peaks[32][4];
bool valid[32] = { false };
for (uint32_t i = 0; i < count; i++) {
uint8_t cur[4];
memcpy(cur, leaves + (size_t)i * 4, 4);
uint32_t level = 0;
while (valid[level]) { // carry: combine with the pending peak at this level
merkle_combine(cur, peaks[level], cur); // peak is earlier (left), cur is right
valid[level] = false;
level++;
if (count == 0 || leaves == nullptr) { memset(out, 0, 4); return; }
MerkleAccumulator accumulator;
for (uint32_t i = 0; i < count; ++i) {
if (!accumulator.add(leaves + (size_t)i * 4)) {
memset(out, 0, 4);
return;
}
memcpy(peaks[level], cur, 4);
valid[level] = true;
}
// bag peaks right-to-left: acc starts at the lowest set level (rightmost peak)
int level = 0;
while (level < 32 && !valid[level]) level++;
uint8_t acc[4];
memcpy(acc, peaks[level], 4);
for (int l = level + 1; l < 32; l++) {
if (valid[l]) merkle_combine(acc, peaks[l], acc); // higher peak is left, acc is right
}
memcpy(out, acc, 4);
if (!accumulator.finish(out)) memset(out, 0, 4);
}
bool merkle_verify(const uint8_t* block, uint32_t block_len, uint32_t index,
+18
View File
@@ -15,6 +15,24 @@
namespace mesh {
namespace ota {
// O(log n) streaming root builder. It accepts leaves in index order and keeps
// only one four-byte peak per tree level, so flash-backed verification never
// needs a block_count*4 scratch buffer.
class MerkleAccumulator {
public:
MerkleAccumulator() { reset(); }
void reset();
bool add(const uint8_t leaf[4]);
bool finish(uint8_t out[4]) const;
uint32_t count() const { return _count; }
private:
uint8_t _peaks[32][4];
uint32_t _valid_mask;
uint32_t _count;
};
// leaf digest of one payload block
void merkle_leaf(uint8_t out[4], const uint8_t* block, uint32_t block_len);
+3
View File
@@ -43,6 +43,7 @@ static char fstate_char(OtaManager::FetchState s) {
case OtaManager::IDLE: return 'I';
case OtaManager::WANT_MANIFEST: return 'W';
case OtaManager::WANT_LEAVES: return 'L';
case OtaManager::VERIFYING_STAGED: return 'V';
case OtaManager::FETCHING: return 'F';
case OtaManager::COMPLETE: return 'C';
case OtaManager::PAUSED: return 'P';
@@ -59,6 +60,7 @@ static const char* state_word(OtaManager::FetchState s) {
case OtaManager::IDLE: return "idle";
case OtaManager::WANT_MANIFEST: return "starting";
case OtaManager::WANT_LEAVES: return "validating seed";
case OtaManager::VERIFYING_STAGED: return "verifying staged blocks";
case OtaManager::FETCHING: return "downloading";
case OtaManager::COMPLETE: return "ready to install";
case OtaManager::FAILED: return "failed";
@@ -72,6 +74,7 @@ static const char* state_short(OtaManager::FetchState s) {
switch (s) {
case OtaManager::WANT_MANIFEST: return "manifest";
case OtaManager::WANT_LEAVES: return "leaves";
case OtaManager::VERIFYING_STAGED: return "verify";
case OtaManager::FETCHING: return "dl";
case OtaManager::COMPLETE: return "done";
case OtaManager::FAILED: return "failed";
+111 -29
View File
@@ -44,6 +44,8 @@ uint8_t* OtaManager::ensureScratch() {
void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
_target = my_target_id; _send = send; _ctx = ctx;
_fstate = IDLE; _have = 0; _fbc = 0;
_resume_verify_idx = 0; _resume_invalidated = false;
_resume_merkle.reset();
_n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false;
_n_src = 0; _n_cat = 0;
}
@@ -591,7 +593,9 @@ void OtaManager::deferCatalog(const uint8_t mid[4], uint32_t until_ms) {
}
bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const {
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == PAUSED) return false; // busy
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return false; // busy
if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it
if (!_archive_fetch && !codecOk(codec)) return false; // can't apply this codec
if (_have_desired_mid) // manual pull of a specific mid
@@ -612,7 +616,9 @@ void OtaManager::clearReassembly() {
// Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest.
void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
(void)target;
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES || _fstate == PAUSED) return;
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return;
_validate = validate; // motatool folder-capture warm-start (seed leaf-diff)
// A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a
// real partial), so never adopt it via resumeStaged - always re-begin and run the manifest->leaves->diff.
@@ -777,14 +783,18 @@ void OtaManager::diffStep() {
OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc);
freeLeaves(); // clears _diffing + frees the buffer
if (_have >= _fbc) { // seed covered the whole image
_fstate = _fetch->finalize() ? COMPLETE : FAILED;
_fstate = storedLeavesRootMatches() && _fetch->finalize()
? COMPLETE : FAILED;
return;
}
_fstate = FETCHING; requestMissing();
}
bool OtaManager::resumeStaged(const uint8_t* want_mid) {
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST) return false;
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED) {
return false;
}
if (!_fetch->reopen()) return false; // nothing persisted in the store
uint32_t total = _fetch->staged_size();
uint8_t hdr[8];
@@ -801,6 +811,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
uint32_t bs = m.block_size();
if (bs == 0 || bs > OTA_MAX_BLOCK) return false;
uint32_t bc = m.block_count;
if (bc == 0 || bc > 0xFFFFu) return false;
if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) return false;
uint32_t leaves_off = 8 + mfl;
uint32_t payload_off = leaves_off + bc * 4;
@@ -811,25 +822,11 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
_fflags = m.flags;
_fpoff = payload_off; _floff = leaves_off; _fpsize = m.payload_size; _fbc = bc; _fbs = bs;
_ftotal = total;
_have = 0;
for (uint32_t i = 0; i < bc; i++) if (blockPresent(i)) _have++; // count blocks whose leaf survived
clearReassembly();
_loop_last_have = 0; _loop_last_mask = 0;
OTA_DBG("OTA: RESUME have=%u/%u total=%u\n", (unsigned)_have, (unsigned)bc, (unsigned)total);
if (_have >= bc) { // already complete -> verify root + finalize
uint8_t* scratch = bc * 4 <= OTA_PROOFGEN_SCRATCH ? ensureScratch() : nullptr;
if (scratch && _fetch->read(_floff, scratch, bc * 4)) {
uint8_t root[4]; merkle_root(root, scratch, bc);
_fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED;
} else {
_fstate = COMPLETE;
}
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
return true;
}
_fstate = FETCHING; // resume fetching the holes
_loop_last_have = _have; _loop_last_mask = _reasm_mask;
beginStagedVerification();
OTA_DBG("OTA: RESUME verifying %u blocks total=%u\n",
(unsigned)bc, (unsigned)total);
return true;
}
@@ -844,6 +841,92 @@ bool OtaManager::blockPresent(uint32_t i) const {
return !(leaf[0]==0xFF && leaf[1]==0xFF && leaf[2]==0xFF && leaf[3]==0xFF);
}
bool OtaManager::storedLeavesRootMatches() const {
if (!_fetch || _fbc == 0) return false;
MerkleAccumulator accumulator;
for (uint32_t i = 0; i < _fbc; ++i) {
uint8_t leaf[4];
if (!_fetch->read(_floff + i * 4, leaf, sizeof(leaf))
|| (leaf[0] == 0xFF && leaf[1] == 0xFF
&& leaf[2] == 0xFF && leaf[3] == 0xFF)
|| !accumulator.add(leaf)) {
return false;
}
}
uint8_t root[4];
return accumulator.finish(root) && memcmp(root, _froot, 4) == 0;
}
void OtaManager::beginStagedVerification() {
_have = 0;
_resume_verify_idx = 0;
_resume_invalidated = false;
_resume_merkle.reset();
_fstate = VERIFYING_STAGED;
}
void OtaManager::verifyStagedStep() {
if (_fstate != VERIFYING_STAGED || !_fetch) return;
static const uint8_t missing_leaf[4] = {0xFF, 0xFF, 0xFF, 0xFF};
for (uint32_t checked = 0;
checked < OTA_DIFF_BATCH && _resume_verify_idx < _fbc;
++checked, ++_resume_verify_idx) {
uint8_t stored_leaf[4];
const uint32_t index = _resume_verify_idx;
if (!_fetch->read(_floff + index * 4,
stored_leaf, sizeof(stored_leaf))) {
_fstate = FAILED;
return;
}
if (memcmp(stored_leaf, missing_leaf, sizeof(stored_leaf)) == 0) {
continue;
}
const uint32_t length = blockLen(index);
if (!_fetch->read(_fpoff + index * _fbs, _reasm_buf, length)) {
_fstate = FAILED;
return;
}
uint8_t computed_leaf[4];
merkle_leaf(computed_leaf, _reasm_buf, length);
if (memcmp(computed_leaf, stored_leaf, sizeof(stored_leaf)) != 0) {
// Payload and marker disagree. Clear the marker so the normal fetch path
// requests this block again; a stale marker must never bless bad bytes.
if (!_fetch->write(_floff + index * 4,
missing_leaf, sizeof(missing_leaf))) {
_fstate = FAILED;
return;
}
_resume_invalidated = true;
continue;
}
if (!_resume_merkle.add(computed_leaf)) {
_fstate = FAILED;
return;
}
_have++;
}
if (_resume_verify_idx < _fbc) return;
clearReassembly();
if (_resume_invalidated) _fetch->checkpoint();
if (_have == _fbc) {
uint8_t root[4];
_fstate = _resume_merkle.finish(root)
&& memcmp(root, _froot, sizeof(root)) == 0
&& _fetch->finalize()
? COMPLETE : FAILED;
} else {
_fstate = FETCHING;
_loop_last_have = _have;
_loop_last_mask = 0;
requestMissing();
}
_resume_merkle.reset();
}
void OtaManager::handleData(const uint8_t* m, uint16_t n) {
DataMsg dm;
if (!decode_data(m, n, dm) || !_fetch) return;
@@ -895,14 +978,9 @@ void OtaManager::handleProof(const uint8_t* m, uint16_t n) {
// cadence is runtime-tunable via `ota config checkpoint <N>` (0 = never)
if (_checkpoint_blocks && _have % _checkpoint_blocks == 0) _fetch->checkpoint();
if (_have < _fbc) { requestMissing(); return; } // next block
// all blocks present -> final root cross-check + finalize
uint8_t* scratch = _fbc * 4 <= OTA_PROOFGEN_SCRATCH ? ensureScratch() : nullptr;
if (scratch && _fetch->read(_floff, scratch, _fbc * 4)) {
uint8_t root[4]; merkle_root(root, scratch, _fbc);
_fstate = (memcmp(root, _froot, 4) == 0) ? COMPLETE : FAILED;
} else {
_fstate = COMPLETE; // per-block proofs already guaranteed integrity vs the root
}
// Every leaf must be readable and collectively match the manifest root.
// A scratch allocation/read failure is an integrity failure, never success.
_fstate = storedLeavesRootMatches() ? COMPLETE : FAILED;
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)");
}
@@ -975,6 +1053,10 @@ void OtaManager::loop() {
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
}
}
if (_fstate == VERIFYING_STAGED) {
verifyStagedStep();
return;
}
if (_fstate == WANT_MANIFEST) {
// Retry GET_MANIFEST ONLY when a tick passed with no new fragment - re-bursting every tick would congest
// the link and burn the retry cap while fragments are still arriving (mirrors FETCHING + WANT_LEAVES).
+16 -3
View File
@@ -7,6 +7,7 @@
#include "OtaStore.h"
#include "MotaContainer.h"
#include "OtaSource.h"
#include "MerkleTree.h"
// Transport-agnostic OTA session engine (docs/ota_protocol.md Section 5/Section 8). It SERVES a complete `.mota`
// (answering GET_MANIFEST / REQ) and/or FETCHES one into an OtaStore (verifying every block against
@@ -146,7 +147,10 @@ public:
// PAUSED: a folder-destination write failed mid-transfer (the seeder link dropped). Progress is held on
// the host; the manager stops requesting and does NOT fall back to RAM/flash. resumeStaged() (called on
// reconnect) re-STATs the host file, recomputes which blocks are missing, and resumes.
enum FetchState : uint8_t { IDLE, WANT_MANIFEST, WANT_LEAVES, FETCHING, COMPLETE, FAILED, PAUSED };
enum FetchState : uint8_t {
IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING,
COMPLETE, FAILED, PAUSED
};
// Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is
// a small uint16, so 0xFFFFFFFF is never valid).
@@ -219,8 +223,9 @@ public:
// Resume a fetch from a container already persisted in the store (after a reboot). want_mid=nullptr
// accepts whatever is staged; otherwise only resumes if the staged manifest_id matches. Re-parses the
// stored manifest, recomputes geometry, counts present blocks, and continues FETCHING the holes (or goes
// straight to COMPLETE if all blocks are present). Returns true if it adopted a staged container.
// stored manifest, recomputes geometry, then incrementally rehashes every staged payload block before
// continuing FETCHING the holes. A fully staged image also has to reproduce the manifest Merkle root
// before it can become COMPLETE. Returns true if it adopted a staged container.
bool resumeStaged(const uint8_t* want_mid);
// Manual cross-target override (decision: deliberate role switch, e.g. companion -> repeater on the
@@ -314,6 +319,8 @@ public:
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _loop_last_mfmask = 0;
freeLeaves(); _validate = false; _archive_fetch = false;
_lv_retries = 0; _loop_last_lvmask = 0;
_resume_verify_idx = 0; _resume_invalidated = false;
_resume_merkle.reset();
}
FetchState fetchState() const { return _fstate; }
@@ -389,6 +396,9 @@ private:
}
void setDigest(uint8_t out[4]) const; // sha2-256:4 over our served mids
bool blockPresent(uint32_t i) const;
bool storedLeavesRootMatches() const;
void beginStagedVerification();
void verifyStagedStep();
void requestMissing();
uint32_t blockLen(uint32_t i) const;
@@ -423,6 +433,9 @@ private:
uint8_t _froot[4] = {0};
uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0;
uint32_t _have = 0;
uint32_t _resume_verify_idx = 0;
bool _resume_invalidated = false;
MerkleAccumulator _resume_merkle;
uint32_t _req_start = 0, _req_count = 0; // last block requested (per-block serial flow; telemetry)
uint32_t _loop_last_have = 0; // for stall detection in loop()
uint32_t _desired_target = 0; // manual cross-target override (0 = auto / own target)
+3 -2
View File
@@ -41,8 +41,9 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_telemetry_history` | `src/helpers/TelemetryHistory.h` | 30-minute rings; seven-day temperature/voltage and dynamically sized GPS retention; exact 1 C temperature/status encoding; separate Base64 series payloads; 14-bit GPS differentials; resize preservation, heap budgets, and 1-based paging bounds |
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered blacklist matching; ordered 1/2/3-byte pbyte rule prefixes; original incoming scope classes and canonical region-name identity; channel-authentication cache key comparison; priority ordering and terminal stop masks; bridge-bucket and regionless channel-target selector encoding; `require=region` and per-channel scope-gate truth tables; fast/slow timing; adding, replacing, and preserving packet scope |
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h` | authenticated sender/timestamp/command matching; owned response storage; empty-response completion; on-air truncation; replacement and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | reserved reply capacity; response priority; push eviction under saturation; response preservation |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
@@ -0,0 +1,155 @@
#include <gtest/gtest.h>
#include <helpers/BleTxStallWatchdog.h>
#include <stdint.h>
#include <vector>
TEST(BleFrameChunks, WritesOneAttPayloadAtATime) {
const uint8_t frame[50] = {};
std::vector<size_t> chunk_lengths;
const size_t written = mesh::writeBleFrameInChunks(
frame, sizeof(frame), 20,
[&chunk_lengths](const uint8_t*, size_t len) {
chunk_lengths.push_back(len);
return len;
});
EXPECT_EQ(written, sizeof(frame));
ASSERT_EQ(chunk_lengths.size(), 3U);
EXPECT_EQ(chunk_lengths[0], 20U);
EXPECT_EQ(chunk_lengths[1], 20U);
EXPECT_EQ(chunk_lengths[2], 10U);
}
TEST(BleFrameChunks, StopsAfterAnAmbiguousLaterFragmentFailure) {
const uint8_t frame[50] = {};
size_t calls = 0;
const size_t written = mesh::writeBleFrameInChunks(
frame, sizeof(frame), 20,
[&calls](const uint8_t*, size_t len) {
calls++;
return calls == 1 ? len : 0;
});
EXPECT_EQ(written, 20U);
EXPECT_EQ(calls, 2U);
}
TEST(BleFrameChunks, PreservesAndStopsAtAShortChunkWrite) {
const uint8_t frame[50] = {};
size_t calls = 0;
const size_t written = mesh::writeBleFrameInChunks(
frame, sizeof(frame), 20,
[&calls](const uint8_t*, size_t len) {
calls++;
return calls == 1 ? len : 5U;
});
EXPECT_EQ(written, 25U);
EXPECT_EQ(calls, 2U);
}
TEST(BleFrameChunks, RejectsInvalidInputsWithoutCallingWriter) {
const uint8_t frame[1] = {};
size_t calls = 0;
auto writer = [&calls](const uint8_t*, size_t len) {
calls++;
return len;
};
EXPECT_EQ(mesh::writeBleFrameInChunks(nullptr, 1, 20, writer), 0U);
EXPECT_EQ(mesh::writeBleFrameInChunks(frame, 0, 20, writer), 0U);
EXPECT_EQ(mesh::writeBleFrameInChunks(frame, 1, 0, writer), 0U);
EXPECT_EQ(calls, 0U);
}
TEST(BleTxStallWatchdog, ExpiresAtTheExactBlockedBoundary) {
mesh::BleTxStallWatchdog watchdog;
EXPECT_FALSE(watchdog.noteBlocked(1000, 10000));
EXPECT_TRUE(watchdog.active());
EXPECT_FALSE(watchdog.noteBlocked(10999, 10000));
EXPECT_TRUE(watchdog.noteBlocked(11000, 10000));
}
TEST(BleTxStallWatchdog, ProgressResetStartsAFreshWindow) {
mesh::BleTxStallWatchdog watchdog;
EXPECT_FALSE(watchdog.noteBlocked(100, 1000));
EXPECT_FALSE(watchdog.noteBlocked(1099, 1000));
watchdog.reset();
EXPECT_FALSE(watchdog.active());
EXPECT_FALSE(watchdog.noteBlocked(1100, 1000));
EXPECT_FALSE(watchdog.noteBlocked(2099, 1000));
EXPECT_TRUE(watchdog.noteBlocked(2100, 1000));
}
TEST(BleTxStallWatchdog, ZeroIsAValidStartTime) {
mesh::BleTxStallWatchdog watchdog;
EXPECT_FALSE(watchdog.noteBlocked(0, 25));
EXPECT_FALSE(watchdog.noteBlocked(24, 25));
EXPECT_TRUE(watchdog.noteBlocked(25, 25));
}
TEST(BleTxStallWatchdog, ElapsedTimeSurvivesMillisRollover) {
mesh::BleTxStallWatchdog watchdog;
const uint32_t start = UINT32_MAX - 50U;
EXPECT_FALSE(watchdog.noteBlocked(start, 100));
EXPECT_FALSE(watchdog.noteBlocked(48, 100));
EXPECT_TRUE(watchdog.noteBlocked(49, 100));
}
TEST(BleElapsedAtLeast, HandlesExactBoundaryAndMillisRollover) {
EXPECT_FALSE(mesh::bleElapsedAtLeast(1099, 100, 1000));
EXPECT_TRUE(mesh::bleElapsedAtLeast(1100, 100, 1000));
const uint32_t start = UINT32_MAX - 50U;
EXPECT_FALSE(mesh::bleElapsedAtLeast(48, start, 100));
EXPECT_TRUE(mesh::bleElapsedAtLeast(49, start, 100));
}
TEST(BleDisconnectRecovery, AttemptsImmediatelyThenThrottlesRetries) {
mesh::BleDisconnectRecovery recovery;
EXPECT_FALSE(recovery.pending());
EXPECT_FALSE(recovery.shouldAttempt(100, 1000));
recovery.begin();
EXPECT_TRUE(recovery.pending());
EXPECT_TRUE(recovery.shouldAttempt(100, 1000));
EXPECT_FALSE(recovery.shouldAttempt(1099, 1000));
EXPECT_TRUE(recovery.shouldAttempt(1100, 1000));
}
TEST(BleDisconnectRecovery, RetryTimingSurvivesMillisRollover) {
mesh::BleDisconnectRecovery recovery;
const uint32_t start = UINT32_MAX - 50U;
recovery.begin();
EXPECT_TRUE(recovery.shouldAttempt(start, 100));
EXPECT_FALSE(recovery.shouldAttempt(48, 100));
EXPECT_TRUE(recovery.shouldAttempt(49, 100));
}
TEST(BleDisconnectRecovery, CompletionStopsRetries) {
mesh::BleDisconnectRecovery recovery;
recovery.begin();
EXPECT_TRUE(recovery.shouldAttempt(0, 100));
recovery.complete();
EXPECT_FALSE(recovery.pending());
EXPECT_FALSE(recovery.shouldAttempt(1000, 100));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -29,6 +29,51 @@ TEST(CompanionFrameQueue, PushTrafficLeavesOneResponseSlot) {
EXPECT_EQ(0x00, queue[0].buf[0]);
}
TEST(CompanionFrameQueue, RequiredPushEvictsBestEffortTraffic) {
TestFrame queue[4] = {
{1, {0x06}}, {1, {0x82}}, {1, {0x88}}, {1, {0x84}}};
size_t count = 4;
ASSERT_TRUE(enqueue(queue, count, 4, 0x8B));
ASSERT_EQ(4U, count);
EXPECT_EQ(0x06, queue[0].buf[0]);
EXPECT_EQ(0x82, queue[1].buf[0]);
EXPECT_EQ(0x8B, queue[2].buf[0]);
EXPECT_EQ(0x88, queue[3].buf[0]);
}
TEST(CompanionFrameQueue, RequiredPushesRemainFifoAheadOfLogs) {
TestFrame queue[6] = {};
size_t count = 0;
ASSERT_TRUE(enqueue(queue, count, 6, 0x88));
ASSERT_TRUE(enqueue(queue, count, 6, 0x82));
ASSERT_TRUE(enqueue(queue, count, 6, 0x84));
ASSERT_TRUE(enqueue(queue, count, 6, 0x85));
ASSERT_EQ(4U, count);
EXPECT_EQ(0x82, queue[0].buf[0]);
EXPECT_EQ(0x85, queue[1].buf[0]);
EXPECT_EQ(0x88, queue[2].buf[0]);
EXPECT_EQ(0x84, queue[3].buf[0]);
}
TEST(CompanionFrameQueue, MessageWaitingCoalesces) {
TestFrame queue[4] = {};
size_t count = 0;
ASSERT_TRUE(enqueue(queue, count, 4, 0x83));
ASSERT_TRUE(enqueue(queue, count, 4, 0x83));
EXPECT_EQ(1U, count);
}
TEST(CompanionFrameQueue, UnknownPushDefaultsToRequired) {
uint8_t unknown = 0xF1;
EXPECT_TRUE(mesh::companionFrameRequiresDelivery(&unknown, 1));
uint8_t packet_log = 0x88;
EXPECT_FALSE(mesh::companionFrameRequiresDelivery(&packet_log, 1));
}
TEST(CompanionFrameQueue, ResponsesRunBeforeQueuedPushesAndRemainFifo) {
TestFrame queue[5] = {};
size_t count = 0;
@@ -8,13 +8,19 @@ TEST(DeferredCliCommand, CopiesAuthenticatedCommandContext) {
memset(secret, 0x5A, sizeof(secret));
const char command[] = "del flood.moderation.all";
ASSERT_TRUE(deferred.enqueue(7, 123456U, 2, secret, command, strlen(command)));
ASSERT_TRUE(deferred.enqueue(7, 123456U, 2, secret, command,
strlen(command), 654321U));
EXPECT_TRUE(deferred.pending);
EXPECT_EQ(7, deferred.client_index);
EXPECT_EQ(123456U, deferred.sender_timestamp);
EXPECT_EQ(654321U, deferred.request_id);
EXPECT_EQ(2, deferred.path_hash_size);
EXPECT_EQ(0, memcmp(secret, deferred.secret, sizeof(secret)));
EXPECT_STREQ(command, deferred.command);
EXPECT_TRUE(deferred.matches(7, 654321U, command, strlen(command)));
EXPECT_FALSE(deferred.matches(6, 654321U, command, strlen(command)));
EXPECT_FALSE(deferred.matches(7, 654322U, command, strlen(command)));
EXPECT_FALSE(deferred.matches(7, 654321U, "region save", 11));
secret[0] = 0;
EXPECT_EQ(0x5A, deferred.secret[0]);
@@ -33,7 +39,9 @@ TEST(DeferredCliCommand, RejectsSecondCommandUntilCleared) {
deferred.clear();
EXPECT_FALSE(deferred.pending);
EXPECT_FALSE(deferred.matches(1, 10U, first, strlen(first)));
EXPECT_EQ(0, deferred.secret[0]);
EXPECT_EQ(0U, deferred.request_id);
EXPECT_EQ(0, deferred.command[0]);
ASSERT_TRUE(deferred.enqueue(2, 11U, 3, secret, second, strlen(second)));
EXPECT_STREQ(second, deferred.command);
@@ -269,6 +269,22 @@ TEST(FloodRuleOrder, NoStopPreservesEveryMatch) {
0x0B, priorities, stop_flags, 4));
}
TEST(FloodRuleOrder, MissingRegionTargetDoesNotStopLowerSafetyRules) {
EXPECT_TRUE(FloodFilterPolicy::stopActionApplies(true, false, false));
EXPECT_TRUE(FloodFilterPolicy::stopActionApplies(true, true, true));
EXPECT_FALSE(FloodFilterPolicy::stopActionApplies(true, true, false));
EXPECT_FALSE(FloodFilterPolicy::stopActionApplies(false, true, true));
const uint8_t priorities[] = {200, 100};
const uint8_t stop_flags[] = {
(uint8_t)(FloodFilterPolicy::stopActionApplies(true, true, false)
? 1 : 0),
0,
};
EXPECT_EQ(0x03U, FloodFilterPolicy::truncateRulesAtStop(
0x03, priorities, stop_flags, 2));
}
TEST(FloodRuleOrder, ThirtyOneSlotTableIncludesTheLastSlot) {
uint8_t priorities[31] = {0};
uint8_t stop_flags[31] = {0};
+107
View File
@@ -194,6 +194,26 @@ TEST(OtaMerkle, BinaryCounterMatchesLevelByLevel) {
}
}
TEST(OtaMerkle, StreamingAccumulatorMatchesContiguousRoot) {
uint8_t leaves[257 * 4];
for (size_t i = 0; i < sizeof(leaves); ++i) {
leaves[i] = (uint8_t)(i * 29U + 7U);
}
MerkleAccumulator accumulator;
for (uint32_t i = 0; i < 257; ++i) {
ASSERT_TRUE(accumulator.add(leaves + i * 4));
}
uint8_t streamed[4], contiguous[4];
ASSERT_TRUE(accumulator.finish(streamed));
merkle_root(contiguous, leaves, 257);
EXPECT_EQ(0, std::memcmp(streamed, contiguous, 4));
EXPECT_EQ(257U, accumulator.count());
accumulator.reset();
EXPECT_FALSE(accumulator.finish(streamed));
EXPECT_EQ(0U, accumulator.count());
}
// Verify every block's proof for several tricky counts, using proofs generated by the Python
// reference (the oracle) - covers deep promotion chains (100, 255, 256, ...).
TEST(OtaMerkle, ReferenceProofsAllIndices) {
@@ -508,6 +528,29 @@ static void pump(OtaManager& client, int guard_max = 200000) {
}
}
static void finish_staged_verification(OtaManager& manager) {
int guard = 10000;
while (manager.fetchState() == OtaManager::VERIFYING_STAGED
&& guard-- > 0) {
manager.loop();
}
ASSERT_GT(guard, 0);
}
class FaultingResumeStore : public OtaStoreRam<4096> {
public:
void failReadAt(uint32_t offset) { _fail_offset = offset; }
bool read(uint32_t offset, uint8_t* buffer,
uint32_t length) const override {
if (offset == _fail_offset) return false;
return OtaStoreRam<4096>::read(offset, buffer, length);
}
private:
uint32_t _fail_offset = UINT32_MAX;
};
// A test MotaSource backing an external "folder" with one or more complete `.mota` images held in RAM -
// the simplest concrete transport (a real device uses serial/BLE/WiFi/FS, same interface). describe()
// parses each container for the catalog + region offsets; read() is a bounds-checked memcpy.
@@ -937,6 +980,8 @@ TEST(OtaTransfer, ResumeAfterReboot) {
client2.begin(SIM_TARGET_ID, sim_send, &to_server2);
client2.set_fetch_store(&store);
ASSERT_TRUE(client2.resumeStaged(nullptr)); // adopt whatever is staged
EXPECT_EQ(client2.fetchState(), OtaManager::VERIFYING_STAGED);
finish_staged_verification(client2);
EXPECT_EQ(client2.blocksHave(), had); // resumed exactly where we left off
EXPECT_EQ(client2.fetchState(), OtaManager::FETCHING);
EXPECT_EQ(client2.blocksTotal(), SIM_MOTA_1K_BLOCKS);
@@ -947,6 +992,68 @@ TEST(OtaTransfer, ResumeAfterReboot) {
EXPECT_EQ(0, std::memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN)); // byte-identical to the original
}
TEST(OtaTransfer, ResumeRehashesPayloadBeforeTrustingPresentLeaf) {
g_q.clear();
OtaManager server, client;
OtaStoreRam<4096> store;
SendTo to_client{&client}, to_server{&server};
server.begin(0, sim_send, &to_client);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
server.announce();
pump(client);
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
MotaManifest staged;
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), staged));
const uint32_t payload_offset =
(uint32_t)(staged.payload - store.data());
const uint32_t leaves_offset =
(uint32_t)(staged.leaves - store.data());
uint8_t damaged = (uint8_t)(staged.payload[0] ^ 0x5A);
ASSERT_TRUE(store.write(payload_offset, &damaged, 1));
OtaManager resumed;
resumed.begin(SIM_TARGET_ID, nullptr, nullptr);
resumed.set_fetch_store(&store);
ASSERT_TRUE(resumed.resumeStaged(nullptr));
finish_staged_verification(resumed);
EXPECT_EQ(resumed.fetchState(), OtaManager::FETCHING);
EXPECT_EQ(resumed.blocksHave() + 1, resumed.blocksTotal());
uint8_t marker[4] = {};
ASSERT_TRUE(store.read(leaves_offset, marker, sizeof(marker)));
const uint8_t missing[4] = {0xFF, 0xFF, 0xFF, 0xFF};
EXPECT_EQ(0, std::memcmp(marker, missing, sizeof(marker)));
}
TEST(OtaTransfer, ResumeReadFailureCanNeverBecomeComplete) {
g_q.clear();
OtaManager server, client;
FaultingResumeStore store;
SendTo to_client{&client}, to_server{&server};
server.begin(0, sim_send, &to_client);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
server.announce();
pump(client);
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
MotaManifest staged;
ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), staged));
store.failReadAt((uint32_t)(staged.leaves - store.data()));
OtaManager resumed;
resumed.begin(SIM_TARGET_ID, nullptr, nullptr);
resumed.set_fetch_store(&store);
ASSERT_TRUE(resumed.resumeStaged(nullptr));
finish_staged_verification(resumed);
EXPECT_EQ(resumed.fetchState(), OtaManager::FAILED);
}
TEST(OtaTransfer, ClientRejectsWrongTarget) {
g_q.clear();
OtaManager server, client;
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <helpers/RemoteCliReplyCache.h>
#include <helpers/RemoteCliRequest.h>
TEST(RemoteCliReplyCache, ReplaysOnlyTheSameAuthenticatedRequest) {
mesh::RemoteCliReplyCache cache;
@@ -25,7 +26,7 @@ TEST(RemoteCliReplyCache, ReplaysOnlyTheSameAuthenticatedRequest) {
mesh::RemoteCliReplyCache::fingerprint("set repeat on", 13)));
}
TEST(RemoteCliReplyCache, OwnsAndReplacesTheRememberedResponse) {
TEST(RemoteCliReplyCache, OwnsAndRetainsRecentResponses) {
mesh::RemoteCliReplyCache cache;
uint8_t first_sender[PUB_KEY_SIZE] = {};
uint8_t sender[PUB_KEY_SIZE] = {};
@@ -39,11 +40,27 @@ TEST(RemoteCliReplyCache, OwnsAndReplacesTheRememberedResponse) {
EXPECT_STREQ("first", cache.response());
ASSERT_TRUE(cache.remember(sender, 2U, 20U, "second"));
EXPECT_FALSE(cache.matches(first_sender, 1U, 10U));
const char* first_response = nullptr;
EXPECT_TRUE(cache.lookup(first_sender, 1U, 10U, &first_response));
EXPECT_STREQ("first", first_response);
EXPECT_TRUE(cache.matches(sender, 2U, 20U));
EXPECT_STREQ("second", cache.response());
}
TEST(RemoteCliReplyCache, RoundRobinEvictionIsBounded) {
mesh::RemoteCliReplyCache cache;
uint8_t sender[PUB_KEY_SIZE] = {};
for (size_t i = 0; i < mesh::RemoteCliReplyCache::ENTRY_COUNT; ++i) {
ASSERT_TRUE(cache.remember(sender, (uint32_t)i + 1,
(uint32_t)i + 100, "OK"));
}
EXPECT_TRUE(cache.matches(sender, 1U, 100U));
ASSERT_TRUE(cache.remember(sender, 999U, 999U, "new"));
EXPECT_FALSE(cache.matches(sender, 1U, 100U));
EXPECT_TRUE(cache.matches(sender, 2U, 101U));
EXPECT_TRUE(cache.matches(sender, 999U, 999U));
}
TEST(RemoteCliReplyCache, EmptyResponseStillMarksRequestComplete) {
mesh::RemoteCliReplyCache cache;
uint8_t sender[PUB_KEY_SIZE] = {};
@@ -79,6 +96,26 @@ TEST(RemoteCliReplyCache, ClearForgetsTheRequestAndResponse) {
EXPECT_STREQ("", cache.response());
}
TEST(RemoteCliRequest, LogicalIdExtensionIsBackwardCompatible) {
uint8_t payload[64] = {};
const char command[] = "get stats";
memcpy(payload + 5, command, strlen(command));
const size_t length = mesh::RemoteCliRequest::append(
payload, sizeof(payload), 5, strlen(command), 0x12345678U);
ASSERT_GT(length, 0U);
EXPECT_STREQ(command, (const char*)payload + 5);
uint32_t logical_id = 0;
EXPECT_TRUE(mesh::RemoteCliRequest::parse(
payload, length, 5, logical_id));
EXPECT_EQ(0x12345678U, logical_id);
uint8_t legacy[32] = {};
memcpy(legacy + 5, command, strlen(command));
EXPECT_FALSE(mesh::RemoteCliRequest::parse(
legacy, sizeof(legacy), 5, logical_id));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();