Enhance fault alert system with region-based scoping and banned channels

Updated the fault alert functionality to include an optional region name for scoping alert floods, allowing operators to override the default scope. Introduced a list of banned channels (e.g., Public PSK, `#test`, `#bot`) to prevent spamming community channels with alerts. The implementation ensures that alerts are only sent to private PSKs or non-banned hashtags. Relevant changes were made across multiple files, including updates to the CLI for setting and retrieving the new `alert.region` preference.
This commit is contained in:
agessaman committed 2026-05-11 13:45:01 -07:00
1 parent 80355f32ab
commit 16dc49fa10
9 files changed
+239 -39

No files matched your search

+80 -15
View File
@@ -66,7 +66,7 @@ static int alert_decode_base64(const char* in, size_t in_len, uint8_t* out) {
#endif
AlertReporter::AlertReporter()
: _prefs(nullptr), _mesh(nullptr),
: _prefs(nullptr), _mesh(nullptr), _callbacks(nullptr),
#ifdef WITH_MQTT_BRIDGE
_bridge(nullptr),
#endif
@@ -77,9 +77,10 @@ AlertReporter::AlertReporter()
#endif
}
void AlertReporter::begin(NodePrefs* prefs, mesh::Mesh* mesh) {
void AlertReporter::begin(NodePrefs* prefs, mesh::Mesh* mesh, CommonCLICallbacks* callbacks) {
_prefs = prefs;
_mesh = mesh;
_callbacks = callbacks;
onConfigChanged();
}
@@ -89,14 +90,60 @@ void AlertReporter::setBridge(MQTTBridge* bridge) {
}
#endif
// Decoded bytes of the well-known PUBLIC group PSK ("izOH6cXN6mrJ5e26oRXNcg==").
// We refuse to use this key for fault alerts so that infrastructure alarms
// never spam every node subscribed to the default Public channel.
static const uint8_t ALERT_PUBLIC_PSK_BYTES[16] = {
0x8B, 0x33, 0x87, 0xE9, 0xC5, 0xCD, 0xEA, 0x6A,
0xC9, 0xE5, 0xED, 0xBA, 0xA1, 0x15, 0xCD, 0x72
// Channels banned as fault-alert destinations. Fault alerts are noisy
// operator-infrastructure messages; routing them to community channels would
// flood every nearby companion app (and amplify via well-known auto-responder
// bots), so the firmware refuses these keys at both CLI set-time and at
// runtime in resolveChannel.
//
// Provenance for each row can be re-derived with:
// printf '#name' | openssl dgst -sha256 | cut -c1-32
// or for raw b64 PSKs:
// echo 'izOH6cXN6mrJ5e26oRXNcg==' | base64 -d | xxd -p
//
// To ban an additional channel: append one new row; no other code changes
// required. The matcher converts the candidate's 16-byte secret to a
// lowercase hex string and does a linear strcmp — N is tiny.
struct BannedAlertChannel {
const char* label;
const char* secret_hex; // 32 lowercase hex chars (no 0x, no separators)
};
static const BannedAlertChannel BANNED_ALERT_CHANNELS[] = {
// Public group PSK ("izOH6cXN6mrJ5e26oRXNcg==")
{ "PUBLIC", "8b3387e9c5cdea6ac9e5edbaa115cd72" },
// sha256("#test")[0..15] — auto-responders in many regions
{ "#test", "9cd8fcf22a47333b591d96a2b848b73f" },
// sha256("#bot")[0..15] — generic bot channel, frequent auto-responders
{ "#bot", "eb50a1bcb3e4e5d7bf69a57c9dada211" },
};
const char* alertReporterBannedChannelMatch(const uint8_t* secret16) {
char hex[33];
static const char* H = "0123456789abcdef";
for (int i = 0; i < 16; i++) {
hex[i*2] = H[(secret16[i] >> 4) & 0xF];
hex[i*2+1] = H[secret16[i] & 0xF];
}
hex[32] = '\0';
for (size_t i = 0; i < sizeof(BANNED_ALERT_CHANNELS) / sizeof(BANNED_ALERT_CHANNELS[0]); i++) {
if (strcmp(hex, BANNED_ALERT_CHANNELS[i].secret_hex) == 0) {
return BANNED_ALERT_CHANNELS[i].label;
}
}
return nullptr;
}
const char* alertReporterBannedChannelMatchB64(const char* psk_b64) {
if (!psk_b64) return nullptr;
size_t len_in = strlen(psk_b64);
if (len_in == 0) return nullptr;
uint8_t secret[32];
int n = alert_decode_base64(psk_b64, len_in, secret);
if (n != 16) return nullptr; // banned table only contains 16-byte secrets
return alertReporterBannedChannelMatch(secret);
}
bool AlertReporter::resolveChannel(mesh::GroupChannel& out) const {
if (!_prefs) return false;
@@ -110,12 +157,15 @@ bool AlertReporter::resolveChannel(mesh::GroupChannel& out) const {
int len = alert_decode_base64(psk, psk_len, out.secret);
if (len != 32 && len != 16) return false;
// Hard refuse the well-known PUBLIC PSK regardless of how it was supplied,
// belt-and-suspenders against an operator pasting it into alert.psk or a
// hashtag whose hash somehow collides (astronomically improbable).
if (len == 16 && memcmp(out.secret, ALERT_PUBLIC_PSK_BYTES, 16) == 0) {
ALERT_DEBUG_PRINTLN("refused PUBLIC PSK for alert channel");
return false;
// Belt-and-suspenders against an operator pasting a banned PSK directly
// into alert.psk, or a hashtag whose hash somehow collides with one of the
// banned 16-byte secrets (astronomically improbable, but free to check).
if (len == 16) {
const char* banned = alertReporterBannedChannelMatch(out.secret);
if (banned) {
ALERT_DEBUG_PRINTLN("refused banned channel '%s' for alert", banned);
return false;
}
}
// PATH_HASH_SIZE bytes — same scheme used by addChannel().
@@ -158,7 +208,22 @@ bool AlertReporter::sendChannel(const char* text) {
ALERT_DEBUG_PRINTLN("createGroupDatagram failed (pool empty?)");
return false;
}
_mesh->sendFlood(pkt);
// Ride the repeater's default scope (or `alert.region` override) when the
// host MyMesh provides one — same path MyMesh uses for adverts and
// broadcast channel messages. Falls back to plain (unscoped) flood when
// no callbacks are wired or no scope is configured, matching the
// pre-scoped behavior on builds without RegionMap.
TransportKey scope;
bool have_scope = _callbacks && _callbacks->resolveAlertScope(scope) && !scope.isNull();
if (have_scope) {
uint16_t codes[2];
codes[0] = scope.calcTransportCode(pkt);
codes[1] = 0;
_mesh->sendFlood(pkt, codes);
} else {
_mesh->sendFlood(pkt);
}
ALERT_DEBUG_PRINTLN("sent: %s", text);
return true;
}
+25 -5
View File
@@ -8,6 +8,23 @@
#include "bridges/MQTTBridge.h"
#endif
/**
* Returns the label of a banned alert channel if \a secret16 matches one of
* the channels in the BANNED_ALERT_CHANNELS table (e.g. "PUBLIC", "#test",
* "#bot"), or nullptr otherwise. Centralized here so both AlertReporter and
* the CommonCLI `set alert.psk` / `set alert.hashtag` handlers can share one
* source of truth — adding a new banned channel is a one-line table edit.
*/
const char* alertReporterBannedChannelMatch(const uint8_t* secret16);
/**
* Convenience: base64-decodes \a psk_b64 and forwards to
* alertReporterBannedChannelMatch. Returns nullptr if not banned (or if
* the input doesn't decode to a 16-byte key — non-16-byte keys cannot
* match any banned entry anyway).
*/
const char* alertReporterBannedChannelMatchB64(const char* psk_b64);
/**
* \brief Send-only group-channel "fault alert" reporter for repeater/observer
* builds.
@@ -19,8 +36,9 @@
*
* The alert channel must be explicitly configured to either a private base64
* PSK (`set alert.psk`) or a hashtag name (`set alert.hashtag`); the
* well-known PUBLIC group key is rejected on purpose, since fault alerts
* would otherwise spam every node subscribed to the default Public channel.
* well-known PUBLIC group key (and a small list of other auto-responder
* channels — see BANNED_ALERT_CHANNELS in AlertReporter.cpp) are rejected on
* purpose so fault alerts never spam community channels.
*
* Edge-triggered + rate-limited via NodePrefs::alert_min_interval_min so a
* flapping link cannot spam the channel.
@@ -37,10 +55,11 @@ public:
/**
* Wire up the reporter. Must be called from MyMesh::begin() after prefs
* are loaded. \a node_name is captured by reference so subsequent rename
* (set name) is reflected automatically.
* are loaded. \a callbacks is optional — when non-null the reporter uses
* it to resolve a TransportKey scope for outgoing alert floods (so the
* packet rides the repeater's default scope or an `alert.region` override).
*/
void begin(NodePrefs* prefs, mesh::Mesh* mesh);
void begin(NodePrefs* prefs, mesh::Mesh* mesh, CommonCLICallbacks* callbacks = nullptr);
#ifdef WITH_MQTT_BRIDGE
/** Bridge can be (re)created lazily; pass nullptr to detach. */
@@ -80,6 +99,7 @@ private:
NodePrefs* _prefs;
mesh::Mesh* _mesh;
CommonCLICallbacks* _callbacks;
#ifdef WITH_MQTT_BRIDGE
MQTTBridge* _bridge;
Fault _wifi;
+52 -14
View File
@@ -2,6 +2,7 @@
#include "CommonCLI.h"
#include "TxtDataHelpers.h"
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include <RTClib.h>
#include <Utils.h>
@@ -320,9 +321,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->alert_hashtag)) {
file.read((uint8_t *)&_prefs->alert_hashtag, sizeof(_prefs->alert_hashtag));
}
if (file.available() >= (int)sizeof(_prefs->alert_region)) {
file.read((uint8_t *)&_prefs->alert_region, sizeof(_prefs->alert_region));
}
// ensure null termination after raw read
_prefs->alert_psk_b64[sizeof(_prefs->alert_psk_b64) - 1] = '\0';
_prefs->alert_hashtag[sizeof(_prefs->alert_hashtag) - 1] = '\0';
_prefs->alert_region[sizeof(_prefs->alert_region) - 1] = '\0';
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@@ -452,6 +457,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->alert_mqtt_minutes, sizeof(_prefs->alert_mqtt_minutes));
file.write((uint8_t *)&_prefs->alert_min_interval_min, sizeof(_prefs->alert_min_interval_min));
file.write((uint8_t *)&_prefs->alert_hashtag, sizeof(_prefs->alert_hashtag));
file.write((uint8_t *)&_prefs->alert_region, sizeof(_prefs->alert_region));
file.close();
}
@@ -1629,11 +1635,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
// Quick character-count check before storing; AlertReporter will redo the
// full base64 decode and reject anything that doesn't yield 16 or 32 bytes.
strcpy(reply, "Error: PSK must be 24 chars (16-byte) or 44 chars (32-byte) base64");
} else if (strcmp(val, "izOH6cXN6mrJ5e26oRXNcg==") == 0 ||
strcmp(val, "izOH6cXN6mrJ5e26oRXNcg") == 0) {
// Refuse the well-known PUBLIC group PSK — fault alerts must not spam
// every node on the default Public channel.
strcpy(reply, "Error: refusing PUBLIC PSK; pick a private key or hashtag");
} else if (const char* banned = alertReporterBannedChannelMatchB64(val)) {
// Refuse any key on the banned channel list (Public PSK, well-known
// auto-responder hashtags like #test/#bot, etc.). Fault alerts on those
// channels would spam every node in the area.
sprintf(reply, "Error: refusing banned channel '%s'; pick a private key or hashtag", banned);
} else {
StrHelper::strncpy(_prefs->alert_psk_b64, val, sizeof(_prefs->alert_psk_b64));
// The new PSK is operator-supplied, so any previously-derived hashtag
@@ -1677,19 +1683,49 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest),
(const uint8_t*)hashtag, (int)strlen(hashtag));
char b64[48];
size_t b64_len = alert_encode_base64(digest, 16, b64, sizeof(b64));
if (b64_len == 0 || b64_len >= sizeof(_prefs->alert_psk_b64)) {
strcpy(reply, "Error: failed to derive PSK from hashtag");
if (const char* banned = alertReporterBannedChannelMatch(digest)) {
// Hashtag derives to a banned key (e.g. `set alert.hashtag test`
// hits the #test entry). Refuse before clobbering existing config.
sprintf(reply, "Error: refusing banned channel '%s'", banned);
} else {
StrHelper::strncpy(_prefs->alert_hashtag, hashtag, sizeof(_prefs->alert_hashtag));
StrHelper::strncpy(_prefs->alert_psk_b64, b64, sizeof(_prefs->alert_psk_b64));
savePrefs();
_callbacks->onAlertConfigChanged();
sprintf(reply, "OK - alert.hashtag: %s", _prefs->alert_hashtag);
char b64[48];
size_t b64_len = alert_encode_base64(digest, 16, b64, sizeof(b64));
if (b64_len == 0 || b64_len >= sizeof(_prefs->alert_psk_b64)) {
strcpy(reply, "Error: failed to derive PSK from hashtag");
} else {
StrHelper::strncpy(_prefs->alert_hashtag, hashtag, sizeof(_prefs->alert_hashtag));
StrHelper::strncpy(_prefs->alert_psk_b64, b64, sizeof(_prefs->alert_psk_b64));
savePrefs();
_callbacks->onAlertConfigChanged();
sprintf(reply, "OK - alert.hashtag: %s", _prefs->alert_hashtag);
}
}
}
}
} else if (memcmp(config, "alert.region", 12) == 0 && (config[12] == 0 || config[12] == ' ')) {
// `set alert.region <name>` overrides the repeater's default_scope for
// alert sends only. `set alert.region` (no arg) clears it. The name is
// looked up lazily via RegionMap at send time; we deliberately don't
// mutate the region map here, so naming an unknown region is allowed
// but will silently fall back to default_scope until the operator runs
// `region put` for it.
const char* val = (config[12] == ' ') ? &config[13] : "";
while (*val == ' ') val++;
size_t len = strlen(val);
if (len == 0) {
_prefs->alert_region[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.region cleared (using default scope)");
} else if (len >= sizeof(_prefs->alert_region)) {
strcpy(reply, "Error: alert.region too long");
} else {
StrHelper::strncpy(_prefs->alert_region, val, sizeof(_prefs->alert_region));
StrHelper::stripSurroundingQuotes(_prefs->alert_region, sizeof(_prefs->alert_region));
savePrefs();
_callbacks->onAlertConfigChanged();
sprintf(reply, "OK - alert.region: %s", _prefs->alert_region);
}
} else if (memcmp(config, "alert.wifi ", 11) == 0) {
int mins = (int)_atoi(&config[11]);
if (mins < 0 || mins > 1440) {
@@ -2030,6 +2066,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %s", _prefs->alert_hashtag[0] ? _prefs->alert_hashtag : "(unset)");
} else if (memcmp(config, "alert.psk", 9) == 0) {
sprintf(reply, "> %s", _prefs->alert_psk_b64[0] ? _prefs->alert_psk_b64 : "(unset)");
} else if (memcmp(config, "alert.region", 12) == 0) {
sprintf(reply, "> %s", _prefs->alert_region[0] ? _prefs->alert_region : "(unset, using default scope)");
} else if (memcmp(config, "alert.wifi", 10) == 0) {
sprintf(reply, "> %u min%s", (unsigned)_prefs->alert_wifi_minutes,
_prefs->alert_wifi_minutes == 0 ? " (disabled)" : "");
+13
View File
@@ -118,6 +118,11 @@ struct NodePrefs { // persisted to file
// text here purely for `get alert.hashtag` readback. A subsequent
// `set alert.psk` clears this field so it doesn't lie about provenance.
char alert_hashtag[24];
// Optional region name (e.g. "us", "eu"); empty = use the repeater's
// default_scope. Looked up lazily via RegionMap::findByNamePrefix at send
// time, so the operator can name a region that doesn't exist yet without
// polluting region_map state. Falls back to default_scope on miss.
char alert_region[31];
};
#ifdef WITH_MQTT_BRIDGE
@@ -298,6 +303,14 @@ public:
virtual bool sendAlertText(const char* /*text*/) {
return false; // no op by default
}
// Resolve the TransportKey scope to use for outgoing fault-alert floods.
// Implementations should consult NodePrefs::alert_region first (look up via
// RegionMap), then fall back to the repeater's default_scope, then return
// false if neither yields a usable key. AlertReporter falls back to an
// unscoped flood when this returns false.
virtual bool resolveAlertScope(TransportKey& /*dest*/) {
return false; // no op by default
}
};
class CommonCLI {