Merge pull request #11 from agessaman/feat/observer-alert

Implement fault alert system with region scoping and PSK handling
This commit is contained in:
Adam Gessaman authored and GitHub committed 2026-05-17 21:31:55 -07:00
commit f1b4b75839
12 files changed
+942 -11

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
#include "AlertReporter.h"
#include <Utils.h>
#include <Packet.h>
#include <string.h>
#include <stdio.h>
// Header layout for PAYLOAD_TYPE_GRP_TXT before encryption:
// [0..3] timestamp (uint32_t LE) — also helps make packet_hash unique
// [4] TXT_TYPE_PLAIN
// [5..] "<sender>: <text>" (null-terminated by sender for legacy parsers)
#ifndef MAX_ALERT_TEXT_LEN
// Conservative ceiling: matches BaseChatMesh::MAX_TEXT_LEN (10 * 16 = 160) and
// stays under MAX_PACKET_PAYLOAD - 4(timestamp) - 1(type) - CIPHER_MAC_SIZE - 1.
#define MAX_ALERT_TEXT_LEN 160
#endif
#ifndef ALERT_TXT_TYPE_PLAIN
#define ALERT_TXT_TYPE_PLAIN 0
#endif
#ifdef MQTT_DEBUG
#include <Arduino.h>
#define ALERT_DEBUG_PRINTLN(...) Serial.printf("Alert: " __VA_ARGS__); Serial.println()
#else
#define ALERT_DEBUG_PRINTLN(...) do {} while (0)
#endif
AlertReporter::AlertReporter()
: _prefs(nullptr), _mesh(nullptr), _callbacks(nullptr),
#ifdef WITH_MQTT_BRIDGE
_bridge(nullptr),
#endif
_next_check_ms(0) {
#ifdef WITH_MQTT_BRIDGE
memset(&_wifi, 0, sizeof(_wifi));
memset(&_mqtt, 0, sizeof(_mqtt));
#endif
}
void AlertReporter::begin(NodePrefs* prefs, mesh::Mesh* mesh, CommonCLICallbacks* callbacks) {
_prefs = prefs;
_mesh = mesh;
_callbacks = callbacks;
onConfigChanged();
}
#ifdef WITH_MQTT_BRIDGE
void AlertReporter::setBridge(MQTTBridge* bridge) {
_bridge = bridge;
}
#endif
// 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 the Public PSK:
// echo 'izOH6cXN6mrJ5e26oRXNcg==' | base64 -d | xxd -p -c 16
//
// To ban an additional channel: append one new row; no other code changes
// required. Both the table entries and `alert_psk_hex` are 32 lowercase hex
// chars (16-byte secret), so the matcher is a direct strcmp.
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];
mesh::Utils::toHex(hex, secret16, 16);
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* alertReporterBannedChannelMatchHex(const char* psk_hex) {
if (!psk_hex || strlen(psk_hex) != 32) return nullptr;
uint8_t secret[16];
if (!mesh::Utils::fromHex(secret, 16, psk_hex)) return nullptr;
return alertReporterBannedChannelMatch(secret);
}
bool AlertReporter::resolveChannel(mesh::GroupChannel& out) const {
if (!_prefs) return false;
// alert_psk_hex is the single source of truth — `set alert.hashtag`
// pre-derives the hex-encoded PSK from sha256("#name")[0..15] at CLI time.
// Only 16-byte secrets (32 hex chars) are supported; 32-byte channel keys
// are not used anywhere in MeshCore practice and not represented in the
// banned table either.
const char* psk = _prefs->alert_psk_hex;
if (strlen(psk) != 32) return false;
memset(out.secret, 0, sizeof(out.secret));
if (!mesh::Utils::fromHex(out.secret, 16, psk)) 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).
const char* banned = alertReporterBannedChannelMatch(out.secret);
if (banned) {
ALERT_DEBUG_PRINTLN("refused banned channel '%s' for alert", banned);
return false;
}
mesh::Utils::sha256(out.hash, sizeof(out.hash), out.secret, 16);
return true;
}
void AlertReporter::onConfigChanged() {
// Reset transient state so a config change re-arms the edge detector.
#ifdef WITH_MQTT_BRIDGE
_wifi.state = OK;
_wifi.fired_at_ms = 0;
for (size_t i = 0; i < sizeof(_mqtt) / sizeof(_mqtt[0]); i++) {
_mqtt[i].state = OK;
_mqtt[i].fired_at_ms = 0;
}
#endif
}
bool AlertReporter::sendChannel(const char* text) {
if (!_mesh || !_prefs) return false;
mesh::GroupChannel channel;
if (!resolveChannel(channel)) return false;
// Build "<sender>: <text>" plaintext payload. Sender = node name (current).
uint8_t buf[5 + MAX_ALERT_TEXT_LEN + 32];
uint32_t timestamp = _mesh->getRTCClock()->getCurrentTime();
memcpy(buf, &timestamp, 4);
buf[4] = ALERT_TXT_TYPE_PLAIN;
const char* sender = _prefs->node_name[0] ? _prefs->node_name : "node";
int n = snprintf((char*)&buf[5], MAX_ALERT_TEXT_LEN, "%s: %s", sender, text);
if (n < 0) return false;
if (n >= MAX_ALERT_TEXT_LEN) n = MAX_ALERT_TEXT_LEN - 1;
mesh::Packet* pkt = _mesh->createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel,
buf, 5 + (size_t)n);
if (!pkt) {
ALERT_DEBUG_PRINTLN("createGroupDatagram failed (pool empty?)");
return false;
}
// 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.
//
// path_hash_size must honor the repeater's configured path.hash.mode (1, 2,
// or 3-byte hashes); the Mesh.h default of 1 would silently downgrade
// observers running on 2/3-byte regional meshes.
const uint8_t path_hash_size = (uint8_t)(_prefs->path_hash_mode + 1);
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, 0, path_hash_size);
} else {
_mesh->sendFlood(pkt, 0, path_hash_size);
}
ALERT_DEBUG_PRINTLN("sent: %s", text);
return true;
}
bool AlertReporter::sendText(const char* text) {
// sendText() is the manual entry point (`alert test` CLI). Deliberately
// does NOT check alert_enabled so operators can verify the PSK / hashtag
// setup without enabling automatic fault firing.
if (!_prefs || !text || !*text) return false;
return sendChannel(text);
}
void AlertReporter::formatAge(unsigned long age_ms, char* out, size_t out_size) const {
unsigned long secs = age_ms / 1000UL;
unsigned long h = secs / 3600UL;
unsigned long m = (secs % 3600UL) / 60UL;
if (h > 0) {
snprintf(out, out_size, "%luh%lum", h, m);
} else {
snprintf(out, out_size, "%lum", m);
}
}
void AlertReporter::onLoop(unsigned long now_ms) {
if (!_prefs || !_prefs->alert_enabled) return;
if (!_mesh) return;
// Throttle: ~5 s cadence. The thresholds are minutes-scale so this is fine.
if ((long)(now_ms - _next_check_ms) < 0) return;
_next_check_ms = now_ms + 5000UL;
#ifdef WITH_MQTT_BRIDGE
// Clamp to a 60-minute floor regardless of what's in NodePrefs. The CLI
// already enforces this on set, but a stale prefs file or future field
// tweak shouldn't be able to drag the floor below 1 hour and let a
// flapping link spam the mesh.
uint16_t cfg_min = _prefs->alert_min_interval_min;
if (cfg_min < 60) cfg_min = 60;
unsigned long min_interval_ms = (unsigned long)cfg_min * 60000UL;
// -------- WiFi fault --------
if (_prefs->alert_wifi_minutes > 0) {
unsigned long wifi_disc_ms = MQTTBridge::getLastWifiDisconnectTime();
unsigned long wifi_conn_ms = MQTTBridge::getWifiConnectedAtMillis();
bool wifi_down = (wifi_disc_ms != 0 && wifi_conn_ms == 0);
unsigned long down_ms = wifi_down ? (now_ms - wifi_disc_ms) : 0;
unsigned long thresh_ms = (unsigned long)_prefs->alert_wifi_minutes * 60000UL;
if (_wifi.state == OK) {
if (wifi_down && down_ms >= thresh_ms &&
(now_ms - _wifi.fired_at_ms) >= min_interval_ms) {
char age[16];
formatAge(down_ms, age, sizeof(age));
uint8_t reason = MQTTBridge::getLastWifiDisconnectReason();
char text[80];
if (reason != 0) {
snprintf(text, sizeof(text), "WiFi down %s (reason %u)", age, (unsigned)reason);
} else {
snprintf(text, sizeof(text), "WiFi down %s", age);
}
if (sendChannel(text)) {
_wifi.state = FIRING;
_wifi.fired_at_ms = now_ms;
_wifi.last_outage_started_ms = wifi_disc_ms;
}
}
} else { // FIRING
if (!wifi_down) {
unsigned long total = (wifi_conn_ms != 0 && _wifi.last_outage_started_ms != 0)
? (wifi_conn_ms - _wifi.last_outage_started_ms) : 0;
char age[16];
formatAge(total, age, sizeof(age));
char text[80];
snprintf(text, sizeof(text), "WiFi recovered after %s", age);
sendChannel(text);
_wifi.state = OK;
}
}
} else if (_wifi.state == FIRING) {
_wifi.state = OK; // threshold disabled mid-fault: silently re-arm
}
// -------- MQTT slot faults --------
if (_prefs->alert_mqtt_minutes > 0 && _bridge != nullptr) {
int n = MQTTBridge::getRuntimeSlotCount();
if (n > (int)(sizeof(_mqtt) / sizeof(_mqtt[0]))) n = (int)(sizeof(_mqtt) / sizeof(_mqtt[0]));
unsigned long thresh_ms = (unsigned long)_prefs->alert_mqtt_minutes * 60000UL;
for (int i = 0; i < n; i++) {
Fault& f = _mqtt[i];
if (!_bridge->isSlotEnabledAndAttempted(i)) {
if (f.state == FIRING) f.state = OK; // slot disabled mid-fault
continue;
}
unsigned long outage_start = _bridge->getSlotCurrentOutageStartMs(i);
bool down = (outage_start != 0);
unsigned long down_ms = down ? (now_ms - outage_start) : 0;
if (f.state == OK) {
if (down && down_ms >= thresh_ms &&
(now_ms - f.fired_at_ms) >= min_interval_ms) {
char age[16];
formatAge(down_ms, age, sizeof(age));
char text[100];
snprintf(text, sizeof(text), "MQTT slot %d (%s) down %s",
i + 1, _bridge->getSlotPresetName(i), age);
if (sendChannel(text)) {
f.state = FIRING;
f.fired_at_ms = now_ms;
f.last_outage_started_ms = outage_start;
}
}
} else { // FIRING
if (!down) {
unsigned long total = (f.last_outage_started_ms != 0)
? (now_ms - f.last_outage_started_ms) : 0;
char age[16];
formatAge(total, age, sizeof(age));
char text[100];
snprintf(text, sizeof(text), "MQTT slot %d (%s) recovered after %s",
i + 1, _bridge->getSlotPresetName(i), age);
sendChannel(text);
f.state = OK;
}
}
}
}
#else
(void)now_ms;
#endif
}
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <Arduino.h>
#include <Mesh.h>
#include "CommonCLI.h"
#ifdef WITH_MQTT_BRIDGE
#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: hex-decodes \a psk_hex (32 lowercase/uppercase hex chars) and
* forwards to alertReporterBannedChannelMatch. Returns nullptr if not banned
* (or if the input isn't a valid 32-char hex string — only 16-byte secrets
* are present in the banned table).
*/
const char* alertReporterBannedChannelMatchHex(const char* psk_hex);
/**
* \brief Send-only group-channel "fault alert" reporter for repeater/observer
* builds.
*
* Polls WiFi and per-MQTT-slot outage timers from MQTTBridge. When any timer
* exceeds its configured threshold, floods a single PAYLOAD_TYPE_GRP_TXT
* message on the configured alert channel ("WiFi down 47m — MyObserver"),
* then arms a "recovered" message for the next state transition.
*
* The alert channel must be explicitly configured to either a private hex
* PSK (`set alert.psk`) or a hashtag name (`set alert.hashtag`); the
* 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.
*
* Designed to compile and run on any repeater build:
* - The channel-send path uses only mesh::Mesh primitives that already
* exist in the Dispatcher hierarchy (createGroupDatagram + sendFlood).
* - WiFi/MQTT polling is #ifdef WITH_MQTT_BRIDGE-gated; without it, the
* reporter still supports manual `alert test` sends.
*/
class AlertReporter {
public:
AlertReporter();
/**
* Wire up the reporter. Must be called from MyMesh::begin() after prefs
* 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, CommonCLICallbacks* callbacks = nullptr);
#ifdef WITH_MQTT_BRIDGE
/** Bridge can be (re)created lazily; pass nullptr to detach. */
void setBridge(MQTTBridge* bridge);
#endif
/**
* Re-derive the cached GroupChannel from \a alert_psk_hex. Call from the
* CLI hot-reload hook after `set alert.psk` / `set alert.hashtag` / `set alert on|off`.
*/
void onConfigChanged();
/**
* Cooperative tick. Fast: returns immediately if disabled, throttled
* internally to ~5 s checks. Safe to call every loop().
*/
void onLoop(unsigned long now_ms);
/**
* Send an arbitrary text immediately (used by `alert test` CLI). Returns
* false when disabled, PSK invalid, or the underlying flood-send fails.
* Bypasses the rate limiter and edge logic.
*/
bool sendText(const char* text);
private:
bool resolveChannel(mesh::GroupChannel& out) const;
bool sendChannel(const char* text);
void formatAge(unsigned long age_ms, char* out, size_t out_size) const;
enum FaultState { OK, FIRING };
struct Fault {
FaultState state;
unsigned long fired_at_ms; // millis() when we last sent a "down" alert
unsigned long last_outage_started_ms; // remembered so the recovered msg can quote duration
};
NodePrefs* _prefs;
mesh::Mesh* _mesh;
CommonCLICallbacks* _callbacks;
#ifdef WITH_MQTT_BRIDGE
MQTTBridge* _bridge;
Fault _wifi;
Fault _mqtt[RUNTIME_MQTT_SLOTS];
#endif
unsigned long _next_check_ms;
};
+234 -2
View File
@@ -2,7 +2,9 @@
#include "CommonCLI.h"
#include "TxtDataHelpers.h"
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include <RTClib.h>
#include <Utils.h>
#ifndef BRIDGE_MAX_BAUD
#define BRIDGE_MAX_BAUD 115200
@@ -278,7 +280,32 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->radio_watchdog_minutes)) {
file.read((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316
}
// next: 317
// Alert channel fields (appended; older files won't have them — defaults from MyMesh ctor remain)
if (file.available() >= (int)sizeof(_prefs->alert_enabled)) {
file.read((uint8_t *)&_prefs->alert_enabled, sizeof(_prefs->alert_enabled));
}
if (file.available() >= (int)sizeof(_prefs->alert_psk_hex)) {
file.read((uint8_t *)&_prefs->alert_psk_hex, sizeof(_prefs->alert_psk_hex));
}
if (file.available() >= (int)sizeof(_prefs->alert_wifi_minutes)) {
file.read((uint8_t *)&_prefs->alert_wifi_minutes, sizeof(_prefs->alert_wifi_minutes));
}
if (file.available() >= (int)sizeof(_prefs->alert_mqtt_minutes)) {
file.read((uint8_t *)&_prefs->alert_mqtt_minutes, sizeof(_prefs->alert_mqtt_minutes));
}
if (file.available() >= (int)sizeof(_prefs->alert_min_interval_min)) {
file.read((uint8_t *)&_prefs->alert_min_interval_min, sizeof(_prefs->alert_min_interval_min));
}
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_hex[sizeof(_prefs->alert_psk_hex) - 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);
@@ -401,7 +428,14 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->snmp_enabled, sizeof(_prefs->snmp_enabled)); // 291
file.write((uint8_t *)&_prefs->snmp_community, sizeof(_prefs->snmp_community)); // 292
file.write((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316
// next: 317
// Alert channel fields (appended)
file.write((uint8_t *)&_prefs->alert_enabled, sizeof(_prefs->alert_enabled));
file.write((uint8_t *)&_prefs->alert_psk_hex, sizeof(_prefs->alert_psk_hex));
file.write((uint8_t *)&_prefs->alert_wifi_minutes, sizeof(_prefs->alert_wifi_minutes));
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();
}
@@ -808,6 +842,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
strcpy(reply, "(OK - stats reset)");
} else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
// Send a one-off test alert on the configured alert channel.
const char* extra = command[10] == ' ' ? &command[11] : "";
char text[120];
if (*extra) {
snprintf(text, sizeof(text), "[test] %s", extra);
} else {
strcpy(text, "[test] alert channel ok");
}
if (!_prefs->alert_psk_hex[0]) {
strcpy(reply, "Error: alert channel not configured (set alert.psk or set alert.hashtag)");
} else {
bool ok = _callbacks->sendAlertText(text);
strcpy(reply, ok ? "OK - alert sent" : "Error: alert send failed (bad PSK or PUBLIC key refused?)");
}
} else if (memcmp(command, "get ", 4) == 0) {
handleGetCmd(sender_timestamp, command, reply);
} else if (memcmp(command, "set ", 4) == 0) {
@@ -1528,6 +1577,173 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
savePrefs();
strcpy(reply, "OK");
#endif
} else if (memcmp(config, "alert ", 6) == 0) {
// set alert on|off
const char* val = &config[6];
if (memcmp(val, "on", 2) == 0 && (val[2] == 0 || val[2] == ' ')) {
_prefs->alert_enabled = 1;
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alerts on");
} else if (memcmp(val, "off", 3) == 0 && (val[3] == 0 || val[3] == ' ')) {
_prefs->alert_enabled = 0;
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alerts off");
} else {
strcpy(reply, "Error: usage set alert on|off");
}
} else if (memcmp(config, "alert.psk", 9) == 0 && (config[9] == 0 || config[9] == ' ')) {
// `set alert.psk` with no argument clears the field (alerts then disabled
// until a new psk/hashtag is configured).
const char* val = (config[9] == ' ') ? &config[10] : "";
while (*val == ' ') val++;
size_t len = strlen(val);
if (len == 0) {
_prefs->alert_psk_hex[0] = '\0';
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.psk cleared (alerts disabled until configured)");
} else if (val[0] == '#') {
strcpy(reply, "Error: use 'set alert.hashtag' for hashtag channels");
} else if (len != 32) {
// 16-byte channel secret = 32 hex chars. This is what the mobile app's
// "Share Channel" emits, what `set alert.hashtag` derives, and what the
// BANNED_ALERT_CHANNELS table holds. 32-byte channels aren't used
// anywhere in MeshCore practice.
strcpy(reply, "Error: PSK must be 32 hex chars (16-byte channel secret)");
} else {
// Validate all-hex, then normalize via fromHex/toHex so the stored
// form is always lowercase regardless of input case.
uint8_t raw[16];
bool all_hex = true;
for (size_t i = 0; i < len; i++) {
if (!mesh::Utils::isHexChar(val[i])) { all_hex = false; break; }
}
if (!all_hex || !mesh::Utils::fromHex(raw, 16, val)) {
strcpy(reply, "Error: PSK must be 32 hex chars (16-byte channel secret)");
} else {
char normalized[33];
mesh::Utils::toHex(normalized, raw, 16);
if (const char* banned = alertReporterBannedChannelMatchHex(normalized)) {
// 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_hex, normalized, sizeof(_prefs->alert_psk_hex));
// The new PSK is operator-supplied, so any previously-derived
// hashtag name is no longer accurate provenance — drop it.
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.psk updated");
}
}
}
} else if (memcmp(config, "alert.hashtag", 13) == 0 && (config[13] == 0 || config[13] == ' ')) {
const char* val = (config[13] == ' ') ? &config[14] : "";
while (*val == ' ') val++;
size_t in_len = strlen(val);
if (in_len == 0) {
_prefs->alert_psk_hex[0] = '\0';
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.hashtag cleared (alerts disabled until configured)");
} else {
// Canonical stored form is "#name" because the leading '#' is part of
// the sha256 input (matching the companion-app hashtag-channel
// derivation in docs/companion_protocol.md). Accept the user typing
// either "alerts" or "#alerts".
char hashtag[sizeof(_prefs->alert_hashtag)];
size_t need = (val[0] == '#') ? in_len : in_len + 1;
if (need >= sizeof(hashtag)) {
strcpy(reply, "Error: hashtag too long");
} else {
if (val[0] == '#') {
StrHelper::strncpy(hashtag, val, sizeof(hashtag));
} else {
hashtag[0] = '#';
StrHelper::strncpy(&hashtag[1], val, sizeof(hashtag) - 1);
}
// Derive the channel key once: first 16 bytes of sha256("#name"),
// store hex-encoded in alert_psk_hex. We don't re-derive on every
// send — operators can later override with `set alert.psk` without
// leaving stale hashtag text behind.
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest),
(const uint8_t*)hashtag, (int)strlen(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 {
char hex[33];
mesh::Utils::toHex(hex, digest, 16);
StrHelper::strncpy(_prefs->alert_hashtag, hashtag, sizeof(_prefs->alert_hashtag));
StrHelper::strncpy(_prefs->alert_psk_hex, hex, sizeof(_prefs->alert_psk_hex));
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) {
strcpy(reply, "Error: alert.wifi must be 0-1440 minutes (0=off)");
} else {
_prefs->alert_wifi_minutes = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.wifi %d min%s", mins, mins == 0 ? " (disabled)" : "");
}
} else if (memcmp(config, "alert.mqtt ", 11) == 0) {
int mins = (int)_atoi(&config[11]);
if (mins < 0 || mins > 10080) {
strcpy(reply, "Error: alert.mqtt must be 0-10080 minutes (0=off)");
} else {
_prefs->alert_mqtt_minutes = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.mqtt %d min%s", mins, mins == 0 ? " (disabled)" : "");
}
} else if (memcmp(config, "alert.interval ", 15) == 0) {
int mins = (int)_atoi(&config[15]);
// Floor at 60 min: faster re-fires would let a flapping link spam the
// mesh with a fresh GRP_TXT flood every minute — terrible for airtime.
if (mins < 60 || mins > 10080) {
strcpy(reply, "Error: alert.interval must be 60-10080 minutes");
} else {
_prefs->alert_min_interval_min = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.interval %d min", mins);
}
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
_prefs->adc_multiplier = atof(&config[15]);
if (_board->setAdcMultiplier(_prefs->adc_multiplier)) {
@@ -1835,6 +2051,22 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#else
strcpy(reply, "ERROR: unsupported");
#endif
} else if (memcmp(config, "alert.hashtag", 13) == 0) {
sprintf(reply, "> %s", _prefs->alert_hashtag[0] ? _prefs->alert_hashtag : "(unset)");
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // from serial command line only
sprintf(reply, "> %s", _prefs->alert_psk_hex[0] ? _prefs->alert_psk_hex : "(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)" : "");
} else if (memcmp(config, "alert.mqtt", 10) == 0) {
sprintf(reply, "> %u min%s", (unsigned)_prefs->alert_mqtt_minutes,
_prefs->alert_mqtt_minutes == 0 ? " (disabled)" : "");
} else if (memcmp(config, "alert.interval", 14) == 0) {
sprintf(reply, "> %u min", (unsigned)_prefs->alert_min_interval_min);
} else if (memcmp(config, "alert", 5) == 0 && (config[5] == 0 || config[5] == '\n' || config[5] == '\r')) {
sprintf(reply, "> %s", _prefs->alert_enabled ? "on" : "off");
} else if (memcmp(config, "adc.multiplier", 14) == 0) {
float adc_mult = _board->getAdcMultiplier();
if (adc_mult == 0.0f) {
+37
View File
@@ -104,6 +104,25 @@ struct NodePrefs { // persisted to file
uint8_t snmp_enabled; // boolean: 0=off, 1=on
char snmp_community[24]; // community string (default "public")
uint8_t radio_watchdog_minutes; // 0=disabled, 1-120 minutes
// Fault alert channel (LoRa group-channel "observer status" message on prolonged WiFi/MQTT outage).
// Sent over the radio (NOT over MQTT) so the alert still works while the MQTT path is broken.
// All fields are appended at the end of NodePrefs for binary-compatible upgrades.
uint8_t alert_enabled; // 0 = off (default), 1 = on
char alert_psk_hex[33]; // 32 lowercase hex chars (16-byte channel secret) + null; empty = alerts disabled. Banned keys (Public/#test/#bot) are rejected.
uint16_t alert_wifi_minutes; // WiFi-down threshold in minutes (0 = disabled), default 30
uint16_t alert_mqtt_minutes; // MQTT-down threshold in minutes (0 = disabled), default 240 (4 h)
uint16_t alert_min_interval_min; // min minutes between alerts for the same fault, default 60, floor 60
// When the operator configures via `set alert.hashtag <name>`, we derive
// alert_psk_hex from sha256("#name")[0..15] once and remember the hashtag
// 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
@@ -274,6 +293,24 @@ public:
virtual void setRxBoostedGain(bool enable) {
// no op by default
};
// Fault-alert channel hooks (see NodePrefs::alert_*). The default no-op
// implementations keep CLI commands harmless on builds that don't wire up
// an AlertReporter.
virtual void onAlertConfigChanged() {
// no op by default
}
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 {
+23
View File
@@ -199,6 +199,25 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const NodePref
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0;
return _slots[slot_index].current_outage_started_ms;
}
bool MQTTBridge::isSlotEnabledAndAttempted(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
const MQTTSlot& s = _slots[slot_index];
return s.enabled && s.initial_connect_done;
}
const char* MQTTBridge::getSlotPresetName(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return "?";
const MQTTSlot& s = _slots[slot_index];
if (s.preset && s.preset->name) return s.preset->name;
if (!s.enabled) return MQTT_PRESET_NONE;
return MQTT_PRESET_CUSTOM;
}
const char* MQTTBridge::wifiReasonStr(uint8_t reason) {
switch (reason) {
case 2: return "auth expired";
@@ -998,6 +1017,7 @@ void MQTTBridge::initSlotClients() {
_slots[index].last_tls_stack_err = 0;
_slots[index].last_sock_errno = 0;
_slots[index].last_error_time = 0;
_slots[index].current_outage_started_ms = 0; // clear current-outage timer for AlertReporter
updateCachedConnectionStatus();
publishStatusToSlot(index);
});
@@ -1007,6 +1027,9 @@ void MQTTBridge::initSlotClients() {
if (_slots[index].first_disconnect_time == 0) {
_slots[index].first_disconnect_time = millis();
}
if (_slots[index].current_outage_started_ms == 0) {
_slots[index].current_outage_started_ms = millis();
}
_slots[index].connected = false;
updateCachedConnectionStatus();
});
+23
View File
@@ -96,6 +96,12 @@ private:
unsigned long last_error_time; // millis() of last error
uint32_t disconnect_count; // Number of disconnect callbacks since boot
unsigned long first_disconnect_time; // millis() of first disconnect after boot
// Current-outage timer (used by AlertReporter to fire faults after a sustained
// outage). Reset to 0 on each successful connect, set to millis() on first
// disconnect-after-connect. first_disconnect_time is intentionally separate
// so the existing 'mqttN.diag' "first_disc" semantics don't change.
unsigned long current_outage_started_ms;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
@@ -381,6 +387,23 @@ public:
bool isReady() const;
static unsigned long getWifiConnectedAtMillis();
/**
* Per-slot outage accessors used by AlertReporter to detect prolonged
* MQTT broker outages. Indices are 0..RUNTIME_MQTT_SLOTS-1.
*
* - getSlotCurrentOutageStartMs(): millis() of the current outage start
* (0 when the slot is connected). Reset on each reconnect.
* - isSlotEnabledAndAttempted(): true when the slot is enabled (preset
* != "none") and has reached at least one connect attempt — i.e. it is
* meaningful to alarm on its connection state.
* - getSlotPresetName(): preset name for friendly status text. Returns
* "custom"/"none"/preset->name; never null.
*/
unsigned long getSlotCurrentOutageStartMs(int slot_index) const;
bool isSlotEnabledAndAttempted(int slot_index) const;
const char* getSlotPresetName(int slot_index) const;
static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; }
/** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */
static void getEffectiveMqttOrigin(const NodePrefs* prefs, char* buf, size_t buf_size);
static void formatMqttStatusReply(char* buf, size_t bufsize, const NodePrefs* prefs);