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
2026-05-11 13:45:01 -07:00
parent 80355f32ab
commit 16dc49fa10
9 changed files with 239 additions and 39 deletions
+25 -4
View File
@@ -684,7 +684,23 @@ The repeater can broadcast a one-line fault notification on a configured group c
The alert is sent over **LoRa** as a `PAYLOAD_TYPE_GRP_TXT` flood packet on the configured channel (with sender = device name) — *not* over MQTT. This is intentional: the MQTT path is what's broken, so the only working delivery is the mesh itself. Anyone in radio range subscribed to the same channel/hashtag in their companion app will see the alert inline with normal channel chat.
> **The default Public channel is intentionally NOT supported.** Fault alerts are operator-infrastructure noise — broadcasting them on the well-known Public PSK would spam every node in the area. The implementation explicitly rejects the Public PSK (`izOH6cXN6mrJ5e26oRXNcg==`) at both the CLI validation step and the alert-send path. You must explicitly point alerts at a **private PSK** (`set alert.psk`) or a **hashtag channel** (`set alert.hashtag`) before alerts can fire.
> **A small list of community channels is intentionally NOT supported.** Fault alerts are operator-infrastructure noise — broadcasting them on shared community channels would spam every node in the area (and on `#test` / `#bot` would amplify via well-known auto-responders). The currently banned destinations are:
>
> - The well-known **Public** group PSK (`izOH6cXN6mrJ5e26oRXNcg==`)
> - **`#test`** (`sha256("#test")[0..15]`)
> - **`#bot`** (`sha256("#bot")[0..15]`)
>
> The list lives in `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); adding a new entry is one line (label + 32 hex chars). The matcher runs at both the CLI validation step (`set alert.psk`, `set alert.hashtag`) and the alert-send path, so a saved-config bypass is still refused at runtime. You must point alerts at a **private PSK** (`set alert.psk`) or a non-banned **hashtag channel** (`set alert.hashtag`) before alerts can fire.
### Scope and routing
Alert floods ride the **repeater's default scope** by default (the same TransportKey used for adverts and channel broadcasts — set via `region default ...`). Operators can override on a per-alert-feature basis with `set alert.region <name>`:
- If `alert.region` is set and the name resolves via `RegionMap`, that region's TransportKey is used.
- If `alert.region` is unset, or the name doesn't resolve, the repeater's `default_scope` is used.
- If both are null, the alert is sent unscoped (matches the pre-scoped firmware's behavior).
`alert.region` is stored as-is — it does **not** create the region. Use `region put <name>` first if it doesn't exist.
### What triggers an alert
@@ -700,6 +716,7 @@ A "recovered" message is sent once when the underlying connection comes back. Af
| `alert` | `off` | Master enable for automatic fault alerts |
| `alert.psk` | *(unset)* | Private base64 PSK (24 or 44 chars). The active channel key. |
| `alert.hashtag` | *(unset)* | Informational only; set via `set alert.hashtag` to pre-derive `alert.psk` from `sha256("#name")[0..15]`. Cleared when `alert.psk` is set directly. |
| `alert.region` | *(unset)* | Optional region name; overrides the repeater's `default_scope` for alert sends only. Empty = use `default_scope`. Looked up lazily via `RegionMap`; unknown names silently fall back to `default_scope`. |
| `alert.wifi` | `30` (min) | 0 disables WiFi alerts |
| `alert.mqtt` | `240` (min) | 0 disables MQTT alerts |
| `alert.interval` | `60` (min) | Minutes between repeat alerts of the same fault. **Hard floor of 60 min** so a flapping link can't spam the mesh; the CLI rejects lower values and AlertReporter clamps stale prefs at runtime. |
@@ -712,14 +729,17 @@ Get:
- `get alert` — master on/off
- `get alert.psk` — the active base64 PSK (or `(unset)`)
- `get alert.hashtag` — the originating hashtag (or `(unset)`, e.g. after `set alert.psk` overrides the hashtag-derived key)
- `get alert.region` — alert-only scope override (or `(unset, using default scope)`)
- `get alert.wifi` / `get alert.mqtt` / `get alert.interval`
Set:
- `set alert on` / `set alert off`
- `set alert.psk <base64>` — 24-char (16-byte) or 44-char (32-byte) base64; rejects the well-known Public PSK. Clears `alert.hashtag` since the new key is operator-supplied.
- `set alert.psk <base64>` — 24-char (16-byte) or 44-char (32-byte) base64; rejects banned channels (Public, `#test`, `#bot`). Clears `alert.hashtag` since the new key is operator-supplied.
- `set alert.psk` (no argument) — clears both `alert.psk` and `alert.hashtag`
- `set alert.hashtag <name>` — derives the 16-byte key from `sha256("#name")` *once*, stores it as `alert.psk`, and remembers the hashtag for `get alert.hashtag`. `#` prefix is added if omitted (so `alerts` and `#alerts` are equivalent).
- `set alert.hashtag <name>` — derives the 16-byte key from `sha256("#name")` *once*, stores it as `alert.psk`, and remembers the hashtag for `get alert.hashtag`. `#` prefix is added if omitted (so `alerts` and `#alerts` are equivalent). Refuses banned hashtag names.
- `set alert.hashtag` (no argument) — clears both `alert.psk` and `alert.hashtag`
- `set alert.region <name>` — alert-only scope override (no region-map mutation; unknown names silently fall back to `default_scope`)
- `set alert.region` (no argument) — clear override, use `default_scope`
- `set alert.wifi <minutes>` (01440; 0 = disabled)
- `set alert.mqtt <minutes>` (010080; 0 = disabled)
- `set alert.interval <minutes>` (6010080; 60-minute floor to protect mesh airtime)
@@ -769,7 +789,8 @@ MyObserver: MQTT slot 1 (analyzer-us) recovered after 4h45m
- Fault state is stored in RAM only — no persistence across reboots.
- The MQTT-slot watcher uses a separate per-slot `current_outage_started_ms` field that is reset on each reconnect, distinct from the `first_disconnect_time` shown in `mqttN.diag` (which remains a "first disconnect since boot" counter for diagnostics).
- WiFi-down alerts can only be delivered if the LoRa radio is up. There is no fallback path.
- The default Public PSK is **rejected** at both `set alert.psk` and at the alert-send path, so even if you somehow set it via a saved config file, the firmware will silently refuse to broadcast on it.
- Banned channels (Public, `#test`, `#bot`) are **rejected** at both `set alert.psk` / `set alert.hashtag` and at the alert-send path, so even if you somehow set one via a saved config file, the firmware will silently refuse to broadcast on it. To add another banned channel, append a row to `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); the format is `{ "label", "32-lowercase-hex-chars" }` (compute as `printf '#name' | openssl dgst -sha256 | cut -c1-32`).
- Alerts are sent via `sendFlood` with the resolved TransportKey codes attached, so they appear on the configured scope just like other broadcast traffic. Operators monitoring a specific region need to be subscribed to that region's scope to hear alerts.
## SNMP Monitoring
+23 -1
View File
@@ -956,6 +956,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.alert_enabled = 0;
_prefs.alert_psk_b64[0] = '\0';
_prefs.alert_hashtag[0] = '\0';
_prefs.alert_region[0] = '\0'; // empty = use default_scope
_prefs.alert_wifi_minutes = 30; // 30 minutes
_prefs.alert_mqtt_minutes = 240; // 4 hours
_prefs.alert_min_interval_min = 60; // re-arm window: 1 hour
@@ -1088,7 +1089,10 @@ void MyMesh::begin(FILESYSTEM *fs) {
#endif
// Wire fault-alert reporter. begin() is safe regardless of bridge state.
_alerter.begin(&_prefs, this);
// Passing `this` as the callbacks lets the reporter resolve a TransportKey
// scope (alert.region override, falling back to default_scope) so alert
// floods ride the same scope as adverts/channel messages.
_alerter.begin(&_prefs, this, this);
#if defined(WITH_MQTT_BRIDGE)
_alerter.setBridge(bridge);
#endif
@@ -1119,6 +1123,24 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3
}
}
bool MyMesh::resolveAlertScope(TransportKey& dest) {
// Prefer an explicit alert.region override; look it up lazily via
// RegionMap so the operator can name a region that doesn't exist yet
// without polluting region_map state — we just silently fall through
// to default_scope on miss.
if (_prefs.alert_region[0]) {
auto r = region_map.findByNamePrefix(_prefs.alert_region);
if (r && region_map.getTransportKeysFor(*r, &dest, 1) > 0 && !dest.isNull()) {
return true;
}
}
if (!default_scope.isNull()) {
dest = default_scope;
return true;
}
return false;
}
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
pending_freq = freq;
+1
View File
@@ -216,6 +216,7 @@ public:
void onAlertConfigChanged() override { _alerter.onConfigChanged(); }
bool sendAlertText(const char* text) override { return _alerter.sendText(text); }
bool resolveAlertScope(TransportKey& dest) override;
bool formatFileSystem() override;
void sendSelfAdvertisement(int delay_millis, bool flood) override;
void updateAdvertTimer() override;
+19
View File
@@ -680,6 +680,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.alert_enabled = 0;
_prefs.alert_psk_b64[0] = '\0';
_prefs.alert_hashtag[0] = '\0';
_prefs.alert_region[0] = '\0';
_prefs.alert_wifi_minutes = 30;
_prefs.alert_mqtt_minutes = 240;
_prefs.alert_min_interval_min = 60;
@@ -804,6 +805,24 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3
}
}
bool MyMesh::resolveAlertScope(TransportKey& dest) {
// Same resolution policy as simple_repeater: alert.region > default_scope.
// The room server doesn't currently embed an AlertReporter, but keeping
// the override in lockstep means the callback path works the same on both
// builds and we won't get caught out if/when it does.
if (_prefs.alert_region[0]) {
auto r = region_map.findByNamePrefix(_prefs.alert_region);
if (r && region_map.getTransportKeysFor(*r, &dest, 1) > 0 && !dest.isNull()) {
return true;
}
}
if (!default_scope.isNull()) {
dest = default_scope;
return true;
}
return false;
}
void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
if (recv_pkt_region && !recv_pkt_region->isWildcard()) { // if _request_ packet scope is known, send reply with same scope
TransportKey scope;
+1
View File
@@ -198,6 +198,7 @@ public:
// CommonCLICallbacks
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
bool resolveAlertScope(TransportKey& dest) override;
bool formatFileSystem() override;
void sendSelfAdvertisement(int delay_millis, bool flood) override;
void updateAdvertTimer() override;
+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 {