diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 6d9efef6..159d6fab 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -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 `: + +- 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 ` 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 ` — 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 ` — 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 ` — 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 ` — 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 ` — 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 ` (0–1440; 0 = disabled) - `set alert.mqtt ` (0–10080; 0 = disabled) - `set alert.interval ` (60–10080; 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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 3e0a2f0e..37d1da5c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a4cb6fa7..57c7a877 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9df3efaa..34c4258b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 74e57e80..11c2dba2 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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; diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index c192a567..19082f9b 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -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; } diff --git a/src/helpers/AlertReporter.h b/src/helpers/AlertReporter.h index e1981a86..eec48802 100644 --- a/src/helpers/AlertReporter.h +++ b/src/helpers/AlertReporter.h @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index fd3c6c9a..32417a6f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2,6 +2,7 @@ #include "CommonCLI.h" #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" +#include "AlertReporter.h" // for alertReporterBannedChannelMatch() #include #include @@ -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 ` 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)" : ""); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 6e38e8c0..e20ba938 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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 {