Merge pull request #11 from agessaman/feat/observer-alert
Implement fault alert system with region scoping and PSK handling
This commit is contained in:
12 files changed
+942
-11
No files matched your search
@@ -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, ×tamp, 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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user