Skip to content
78 changes: 78 additions & 0 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore
- [GPS](#gps-when-gps-support-is-compiled-in)
- [Sensors](#sensors-when-sensor-support-is-compiled-in)
- [Bridge](#bridge-when-bridge-support-is-compiled-in)
- [Channel Content Filter](#channel-content-filter-when-channel-filtering-is-compiled-in)

---

Expand Down Expand Up @@ -1100,3 +1101,80 @@ region save
**Note:** Returns an error on boards without power management support.

---

### Channel Content Filter (When channel filtering is compiled in)

Repeater only. Lets a repeater decrypt channels it holds the key for, inspect the plaintext, and refuse to retransmit messages that match a blocked keyword or sender name. With nothing configured, behaviour is identical to a stock repeater.

**How it works:** the repeater only decrypts channels you explicitly load a key for (see `filter channel`). For those channels it reads the sender name and message text; any message matching a blocked keyword (text, case-insensitive substring) or blocked sender (case-insensitive substring of the sender name) is dropped instead of forwarded. All other channels — and direct messages — are never decrypted and forward exactly as before.

**Unicode handling:** before matching, both the message and your blocked terms are Unicode-folded so common evasion tricks don't slip through — look-alike characters (fullwidth, mathematical bold/italic, circled/squared letters, regional-indicator "flag" letters, and common Cyrillic/Greek homoglyphs) are mapped to the plain ASCII letter they imitate, accents are stripped, and zero-width / combining / variation-selector characters are removed.

**Limitations:**
- This only stops **this** repeater from forwarding the message. Other repeaters running stock firmware still forward it, so this thins coverage at your node rather than removing the message from the mesh.
- Only works for channels whose key the repeater holds (the built-in public channel, plus any channel PSK you add).
- Sender names are self-declared in the channel payload and easily spoofed, so `filter sender` is a weak control on its own.

**Config storage:** persisted to `/channel_filter` on the node's filesystem.

---

#### Show the current filter configuration
**Usage:**
- `filter`
- `filter list`

**Note:** Reports channel/keyword/sender counts, the lifetime filtered-message count, and the configured keyword and sender terms.

---

#### View or reset the filtered-message counter
**Usage:**
- `filter stats`
- `filter stats reset`

**Note:** `filter stats` reports `filtered:<n> channels:<n> keywords:<n> senders:<n>`. The counter is a runtime value and is not persisted, so it also resets on reboot.

---

#### Add or remove a channel to decrypt
**Usage:**
- `filter channel <psk>`
- `filter channel public`
- `filter channel clear`

**Parameters:**
- `psk`: Channel pre-shared key in Base64 (16 or 32 bytes when decoded), e.g. the value shared by a MeshCore client. The literal `public` is a shortcut for the well-known public channel key.

**Note:** Adding a channel only enables decryption/inspection of that channel; messages still forward normally unless they match a blocked keyword or sender. `filter channel clear` removes all channel keys (the repeater stops decrypting and forwards everything blind again).

---

#### Block a keyword
**Usage:**
- `filter block <keyword>`

**Parameters:**
- `keyword`: Text to match (case-insensitive substring) against the whole message. Max 23 characters.

**Note:** Matches anywhere in the message — both the body and the sender name — so a blocked word can't be hidden in a self-declared sender name.

---

#### Block a sender name
**Usage:**
- `filter sender <name>`

**Parameters:**
- `name`: Text to match (case-insensitive substring) against the sender's display name. Max 23 characters.

---

#### Clear or reset filter terms
**Usage:**
- `filter clear`
- `filter reset`

**Note:** `filter clear` empties the keyword and sender lists but keeps the loaded channel keys. `filter reset` wipes everything — channel keys, keywords and senders.

---
258 changes: 258 additions & 0 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
#include "MyMesh.h"
#include <algorithm>

#ifdef WITH_CHANNEL_FILTER
#include "UnicodeFold.h"

/* --------------------- public-channel content filter ------------------ */

// The well-known MeshCore public channel PSK ("izOH6cXN6mrJ5e26oRXNcg==")
static const uint8_t PUBLIC_CHANNEL_SECRET[16] = {
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
};

static int b64Val(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}

static int decodeBase64(const char* in, uint8_t* out, int max_out) {
int bits = 0, nbits = 0, n = 0;
for (const char* p = in; *p && *p != '='; p++) {
int v = b64Val(*p);
if (v < 0) continue; // skip whitespace and other non-alphabet chars
bits = (bits << 6) | v;
nbits += 6;
if (nbits >= 8) {
nbits -= 8;
if (n >= max_out) return -1;
out[n++] = (bits >> nbits) & 0xFF;
}
}
return n;
}
#endif // WITH_CHANNEL_FILTER


/* ------------------------------ Config -------------------------------- */

#ifndef LORA_FREQ
Expand Down Expand Up @@ -627,6 +665,213 @@ void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) {
}
}

#ifdef WITH_CHANNEL_FILTER
bool MyMesh::addFilterChannel(const char *psk_b64) {
if (num_filter_channels >= MAX_FILTER_CHANNELS) return false;

auto ch = &filter_channels[num_filter_channels];
memset(ch->secret, 0, sizeof(ch->secret));

int len;
if (strcmp(psk_b64, "public") == 0) {
memcpy(ch->secret, PUBLIC_CHANNEL_SECRET, sizeof(PUBLIC_CHANNEL_SECRET));
len = sizeof(PUBLIC_CHANNEL_SECRET);
} else {
len = decodeBase64(psk_b64, ch->secret, sizeof(ch->secret));
}
if (len != 16 && len != 32) return false;

mesh::Utils::sha256(ch->hash, sizeof(ch->hash), ch->secret, len);
StrHelper::strncpy(filter_channel_psk[num_filter_channels], psk_b64, FILTER_PSK_B64_LEN);
num_filter_channels++;
return true;
}

int MyMesh::searchChannelsByHash(const uint8_t *hash, mesh::GroupChannel channels[], int max_matches) {
int n = 0;
for (int i = 0; i < num_filter_channels && n < max_matches; i++) {
if (filter_channels[i].hash[0] == hash[0]) {
channels[n++] = filter_channels[i];
}
}
return n;
}

void MyMesh::onGroupDataRecv(mesh::Packet *packet, uint8_t type, const mesh::GroupChannel &channel,
uint8_t *data, size_t len) {
if (type != PAYLOAD_TYPE_GRP_TXT) return; // only inspect channel text messages
if (len < 6) return;
if ((data[4] >> 2) != 0) return; // not a plain-text message
if (len >= MAX_PACKET_PAYLOAD) return; // crafted over-long payload; avoid OOB on data[len]

data[len] = 0; // make a C string: "sender_name: text"
const char *msg = (const char *)&data[5];

const char *sep = strstr(msg, ": ");

// Unicode-fold so homoglyph / zero-width tricks can't evade the blocklist (see
// UnicodeFold.h). Keywords match the whole message (sender + text) so a blocked
// word can't be hidden in the self-declared sender name. Terms are folded the
// same way at match time.
char folded_msg[MAX_PACKET_PAYLOAD];
ufold::foldUtf8(msg, folded_msg, sizeof(folded_msg));

char folded_sender[40];
folded_sender[0] = 0;
if (sep) {
char sender[40];
int slen = sep - msg;
if (slen >= (int)sizeof(sender)) slen = sizeof(sender) - 1;
memcpy(sender, msg, slen);
sender[slen] = 0;
ufold::foldUtf8(sender, folded_sender, sizeof(folded_sender));
}

bool blocked = false;
const char *reason = "keyword";
char fterm[FILTER_TERM_LEN];

for (int i = 0; i < num_block_senders && !blocked; i++) {
ufold::foldUtf8(block_senders[i], fterm, sizeof(fterm));
if (fterm[0] && strstr(folded_sender, fterm)) { blocked = true; reason = "sender"; }
}
for (int i = 0; i < num_block_keywords && !blocked; i++) {
ufold::foldUtf8(block_keywords[i], fterm, sizeof(fterm));
if (fterm[0] && strstr(folded_msg, fterm)) blocked = true;
}

if (blocked) {
packet->markDoNotRetransmit(); // routeRecvPacket() will now release instead of forwarding
n_filtered++;
MESH_DEBUG_PRINTLN("filter: dropping channel msg (%s): %s", reason, msg);
if (_logging) {
File f = openAppend(PACKET_LOG_FILE);
if (f) {
f.print(getLogDateTime());
f.printf(": FILTERED (%s): %s\n", reason, msg);
f.close();
}
}
}
}

void MyMesh::loadChannelFilter() {
num_filter_channels = 0;
num_block_keywords = 0;
num_block_senders = 0;

#if defined(RP2040_PLATFORM)
File f = _fs->open(CHANNEL_FILTER_FILE, "r");
#else
File f = _fs->open(CHANNEL_FILTER_FILE);
#endif
if (!f) return;

char line[FILTER_PSK_B64_LEN + 8];
while (f.available()) {
int n = f.readBytesUntil('\n', (uint8_t *)line, sizeof(line) - 1);
line[n] = 0;
while (n > 0 && (line[n - 1] == '\r' || line[n - 1] == ' ')) line[--n] = 0;
if (n < 3 || line[1] != ' ') continue;

const char *val = &line[2];
if (line[0] == 'C') {
addFilterChannel(val);
} else if (line[0] == 'K' && num_block_keywords < MAX_FILTER_TERMS) {
StrHelper::strncpy(block_keywords[num_block_keywords++], val, FILTER_TERM_LEN);
} else if (line[0] == 'S' && num_block_senders < MAX_FILTER_TERMS) {
StrHelper::strncpy(block_senders[num_block_senders++], val, FILTER_TERM_LEN);
}
}
f.close();
}

void MyMesh::saveChannelFilter() {
_fs->remove(CHANNEL_FILTER_FILE);
File f = openAppend(CHANNEL_FILTER_FILE);
if (!f) return;
for (int i = 0; i < num_filter_channels; i++) f.printf("C %s\n", filter_channel_psk[i]);
for (int i = 0; i < num_block_keywords; i++) f.printf("K %s\n", block_keywords[i]);
for (int i = 0; i < num_block_senders; i++) f.printf("S %s\n", block_senders[i]);
f.close();
}

void MyMesh::handleFilterCommand(char *command, char *reply) {
char *arg = command + 6; // skip "filter"
while (*arg == ' ') arg++;

if (*arg == 0 || strcmp(arg, "list") == 0) {
char *dp = reply;
dp += sprintf(dp, "channels:%d keywords:%d senders:%d filtered:%u", num_filter_channels,
num_block_keywords, num_block_senders, (unsigned)n_filtered);
for (int i = 0; i < num_block_keywords && dp - reply < 120; i++) dp += sprintf(dp, "\nK:%s", block_keywords[i]);
for (int i = 0; i < num_block_senders && dp - reply < 120; i++) dp += sprintf(dp, "\nS:%s", block_senders[i]);
return;
}
if (memcmp(arg, "stats", 5) == 0 && (arg[5] == 0 || arg[5] == ' ')) {
char *sub = arg + 5;
while (*sub == ' ') sub++;
if (strcmp(sub, "reset") == 0) {
n_filtered = 0;
strcpy(reply, "OK - stats reset");
} else {
sprintf(reply, "filtered:%u channels:%d keywords:%d senders:%d", (unsigned)n_filtered,
num_filter_channels, num_block_keywords, num_block_senders);
}
return;
}
if (memcmp(arg, "channel ", 8) == 0) {
char *val = arg + 8;
while (*val == ' ') val++;
if (strcmp(val, "clear") == 0) {
num_filter_channels = 0;
saveChannelFilter();
strcpy(reply, "OK - channels cleared");
} else if (addFilterChannel(val)) {
saveChannelFilter();
sprintf(reply, "OK - %d channel(s)", num_filter_channels);
} else {
strcpy(reply, "Err - bad PSK or list full");
}
return;
}
if (memcmp(arg, "block ", 6) == 0) {
char *val = arg + 6;
while (*val == ' ') val++;
if (*val == 0) { strcpy(reply, "Err - empty keyword"); return; }
if (num_block_keywords >= MAX_FILTER_TERMS) { strcpy(reply, "Err - keyword list full"); return; }
StrHelper::strncpy(block_keywords[num_block_keywords++], val, FILTER_TERM_LEN);
saveChannelFilter();
sprintf(reply, "OK - %d keyword(s)", num_block_keywords);
return;
}
if (memcmp(arg, "sender ", 7) == 0) {
char *val = arg + 7;
while (*val == ' ') val++;
if (*val == 0) { strcpy(reply, "Err - empty sender"); return; }
if (num_block_senders >= MAX_FILTER_TERMS) { strcpy(reply, "Err - sender list full"); return; }
StrHelper::strncpy(block_senders[num_block_senders++], val, FILTER_TERM_LEN);
saveChannelFilter();
sprintf(reply, "OK - %d sender(s)", num_block_senders);
return;
}
if (strcmp(arg, "clear") == 0) {
num_block_keywords = 0;
num_block_senders = 0;
saveChannelFilter();
strcpy(reply, "OK - blocks cleared");
return;
}
if (strcmp(arg, "reset") == 0) {
num_filter_channels = num_block_keywords = num_block_senders = 0;
saveChannelFilter();
strcpy(reply, "OK - filter reset");
return;
}
strcpy(reply, "Err - usage: filter [list|stats [reset]|channel <b64|public|clear>|block <kw>|sender <name>|clear|reset]");
}
#endif // WITH_CHANNEL_FILTER

static bool isShare(const mesh::Packet *packet) {
if (packet->hasTransportCodes()) {
return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere'
Expand Down Expand Up @@ -862,6 +1107,12 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
{
last_millis = 0;
uptime_millis = 0;
#ifdef WITH_CHANNEL_FILTER
num_filter_channels = 0;
num_block_keywords = 0;
num_block_senders = 0;
n_filtered = 0;
#endif
next_local_advert = next_flood_advert = 0;
dirty_contacts_expiry = 0;
set_radio_at = revert_radio_at = 0;
Expand Down Expand Up @@ -930,6 +1181,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
_cli.loadPrefs(_fs);
acl.load(_fs, self_id);
#ifdef WITH_CHANNEL_FILTER
loadChannelFilter();
#endif
// TODO: key_store.begin();
region_map.load(_fs);

Expand Down Expand Up @@ -1257,6 +1511,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
#ifdef WITH_CHANNEL_FILTER
} else if (memcmp(command, "filter", 6) == 0 && (command[6] == 0 || command[6] == ' ')) {
handleFilterCommand(command, reply);
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
Expand Down
Loading