From 87636b211780be9c17fd69f579a85b3d517c9d94 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 17 Jun 2026 11:14:27 +0200 Subject: [PATCH] Add Packet Filter --- examples/simple_repeater/Filter.cpp | 472 ++++++++++++++++++++++++++++ examples/simple_repeater/Filter.h | 121 +++++++ examples/simple_repeater/MyMesh.cpp | 11 +- examples/simple_repeater/MyMesh.h | 3 +- 4 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 examples/simple_repeater/Filter.cpp create mode 100644 examples/simple_repeater/Filter.h diff --git a/examples/simple_repeater/Filter.cpp b/examples/simple_repeater/Filter.cpp new file mode 100644 index 0000000000..7e2ab17857 --- /dev/null +++ b/examples/simple_repeater/Filter.cpp @@ -0,0 +1,472 @@ +#include "Filter.h" + +bool Filter::allowPacketForward(const mesh::Packet* packet) { + if (!_prefs.filter_enabled) return true; + + // do not filter direct + if (packet->isRouteDirect()) return true; + + // priority + if (hasPriority(packet)) return true; + + // multi hash bytes + if (packet->getPathHashSize() < _prefs.minimal_hash_bytes) { + _cnt.hash++; + return false; + } + + uint8_t type = packet->getPayloadType(); + if (type < PAYLOAD_TYPE_COUNT) { + // hops max + if (packet->getPathHashCount() >= _prefs.payload_prefs[type].hops_max) { + _cnt.hops[type]++; + return false; + } + // rate limiter + if (!_limiters[type].allow(_rtc->getCurrentTime())) { + _cnt.rate[type]++; + return false; + } + } + + // channels + if (type == PAYLOAD_TYPE_GRP_TXT) { + if (packet->payload_len <= PATH_HASH_SIZE + CIPHER_MAC_SIZE) return false; + + uint8_t channel_hash = packet->payload[0]; + + // blocked + for (int i=0; ipayload[PATH_HASH_SIZE], packet->payload_len - PATH_HASH_SIZE); + if (!validMessageContent(data, len)) { + _cnt.malformed++; + return false; + } + } + } + } + + // allowed + return true; +} + +bool Filter::hasPriority(const mesh::Packet* packet) { + uint8_t type = packet->getPayloadType(); + if (type != PAYLOAD_TYPE_REQ && + type != PAYLOAD_TYPE_RESPONSE && + type != PAYLOAD_TYPE_TXT_MSG && + type != PAYLOAD_TYPE_ANON_REQ && + type != PAYLOAD_TYPE_PATH) return false; + + if (packet->payload_len < 2) return false; + + uint8_t dst_hash = packet->payload[0]; + uint8_t src_hash = packet->payload[1]; + + // check ACL contacts + for (int i = 0; i < _acl->getNumClients(); i++) { + ClientInfo* client = _acl->getClientByIdx(i); + if (client->id.isHashMatch(&src_hash) || client->id.isHashMatch(&dst_hash)) return true; + } + return false; +} + +void Filter::handleCommand(FILESYSTEM* fs, char* command, char* reply) { + const char* parts[6]; + int n = mesh::Utils::parseTextParts(command, parts, 6, ' '); + + if (n <= 1) { + uint32_t hops_total = 0; + uint32_t rate_total = 0; + for (uint8_t i = 0; i < PAYLOAD_TYPE_COUNT; ++i) { + hops_total += _cnt.hops[i]; + rate_total += _cnt.rate[i]; + } + sprintf(reply, "> Filter %s: Blocked [ Hops: %d | Rate: %d | Channel: %d | Hash: %d | Malformed: %d ]", + _prefs.filter_enabled ? "on" : "off", + hops_total, rate_total, _cnt.channel, _cnt.hash, _cnt.malformed); + } + + if (n == 2) { + if (strcmp(parts[1], "help") == 0) { + strcpy(reply, "> filter [ help | on | off | reset | types | count | hops | rate | channel | hash | malformed ]"); + } else if (strcmp(parts[1], "types") == 0) { + strcpy(reply, "00=REQ\n01=RESPONSE\n02=TXT_MSG\n03=ACK\n04=ADVERT\n05=GRP_TXT\n06=GRP_DATA\n07=ANON_REQ\n08=PATH\n09=TRACE\n10=MULTIPART\n11=CONTROL"); + } else if (strcmp(parts[1], "on") == 0) { + _prefs.filter_enabled = true; + strcpy(reply, "> Filter: on"); + save(fs); + } else if (strcmp(parts[1], "off") == 0) { + _prefs.filter_enabled = false; + strcpy(reply, "> Filter: off"); + save(fs); + } else if (strcmp(parts[1], "reset") == 0) { + resetPrefs(); + strcpy(reply, "> Filter: preferences reset"); + save(fs); + } else if (strcmp(parts[1], "count") == 0) { + formatResponse(reply, ResponseType::COUNT); + } else if (strcmp(parts[1], "hops") == 0) { + formatResponse(reply, ResponseType::HOPS); + } else if (strcmp(parts[1], "rate") == 0) { + formatResponse(reply, ResponseType::RATE); + } else if (strcmp(parts[1], "channel") == 0) { + strcpy(reply, "> filter channel [list | add | remove] <#name | Public>"); + } else if (strcmp(parts[1], "hash") == 0) { + sprintf(reply, "> Filter: minimal %d bytes path hash size", _prefs.minimal_hash_bytes); + } else if (strcmp(parts[1], "malformed") == 0) { + sprintf(reply, "> Filter: malformed text scan %s", _prefs.filter_malformed ? "on" : "off"); + } else { + strcpy(reply, "> Filter: command error"); + } + } + + if (n >= 3) { + + // hops + if (strcmp(parts[1], "hops") == 0) { + + if (n == 4) { + uint8_t type = atoi(parts[2]); + uint8_t count = atoi(parts[3]); + + if (type < 0 || type >= PAYLOAD_TYPE_COUNT) { + strcpy(reply, "> Filter: error range is 0-10"); + } else if (count < 0 || count > 64) { + strcpy(reply, "> Filter: error range is 0-64"); + } else { + _prefs.payload_prefs[type].hops_max = count; + save(fs); + strcpy(reply, "> Filter: OK"); + } + } else { + strcpy(reply, "> Filter: syntax error 'filter hops '"); + } + + // rate + } else if (strcmp(parts[1], "rate") == 0) { + + if (n == 5) { + uint8_t type = atoi(parts[2]); + uint16_t limit = atoi(parts[3]); + uint32_t secs = atoi(parts[4]); + + if (type < 0 || type >= PAYLOAD_TYPE_COUNT) { + strcpy(reply, "> Filter: error type range is 0-10"); + } else { + _prefs.payload_prefs[type].rate_limit = limit; + _prefs.payload_prefs[type].rate_secs = secs; + _limiters[type].init(limit, secs); + save(fs); + strcpy(reply, "> Filter: OK"); + } + } else { + strcpy(reply, "> Filter: syntax error 'filter rate '"); + } + + // channel + } else if (strcmp(parts[1], "channel") == 0) { + + if (strcmp(parts[2], "list") == 0) { + listChannelNames(reply, 160); + } else if (n >= 4 && strcmp(parts[2], "add") == 0) { + if (addChannel(parts[3])) { + sprintf(reply, "> Filter: channel %s added", parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else if (n >= 4 && strcmp(parts[2], "remove") == 0) { + if (removeChannel(parts[3])) { + sprintf(reply, "> Filter: channel %s removed", parts[3]); + save(fs); + } else { + strcpy(reply, "Failed"); + } + } else { + strcpy(reply, "> Filter: syntax error 'filter channel [list | add | remove] <#name | Public>'"); + } + + // hash + } else if (strcmp(parts[1], "hash") == 0) { + uint8_t count = atoi(parts[2]); + if (count < 1 || count > 3) { + strcpy(reply, "> Filter: error hash bytes range is 1-3"); + } else { + _prefs.minimal_hash_bytes = count; + save(fs); + strcpy(reply, "> Filter: OK"); + } + + // malformed + } else if (strcmp(parts[1], "malformed") == 0) { + if (strcmp(parts[2], "on") == 0) { + _prefs.filter_malformed = true; + strcpy(reply, "> Filter: malformed scan on"); + save(fs); + } else if (strcmp(parts[2], "off") == 0) { + _prefs.filter_malformed = false; + strcpy(reply, "> Filter: malformed scan off"); + save(fs); + } + } else { + strcpy(reply, "> Filter: command error"); + } + } +} + +void Filter::formatResponse(char *reply, ResponseType rtype) { + uint8_t buflen = 160; + uint8_t n = 0; + uint8_t pos = 0; + + if (rtype == ResponseType::HOPS) { + pos = snprintf(reply, buflen, "[TYPE: MAX_HOPS]\n"); + } else if (rtype == ResponseType::RATE) { + pos = snprintf(reply, buflen, "[TYPE: LIMIT,SECS]\n"); + } else if (rtype == ResponseType::COUNT) { + pos = snprintf(reply, buflen, "[TYPE: HOPS,RATE]\n"); + } + + for (uint8_t i = 0; i < PAYLOAD_TYPE_COUNT; ++i) { + if (rtype == ResponseType::HOPS) { + n = snprintf(reply + pos, (pos < buflen) ? (buflen - pos) : 0, + "%02d: %d", + i, + _prefs.payload_prefs[i].hops_max); + } else if (rtype == ResponseType::RATE){ + n = snprintf(reply + pos, (pos < buflen) ? (buflen - pos) : 0, + "%02d: %d,%d", + i, + _prefs.payload_prefs[i].rate_limit, + _prefs.payload_prefs[i].rate_secs); + } else if (rtype == ResponseType::COUNT){ + n = snprintf(reply + pos, (pos < buflen) ? (buflen - pos) : 0, + "%02d: %d,%d", + i, + _cnt.hops[i], + _cnt.rate[i]); + } + if (n < 0) return; + if (n >= (buflen - pos)) { + pos = buflen - 1; + reply[pos] = '\0'; + return; + } + pos += (size_t)n; + + if (i + 1 < PAYLOAD_TYPE_COUNT) { + if (pos + 1 >= buflen) { + reply[buflen - 1] = '\0'; + return; + } + reply[pos++] = '\n'; + reply[pos] = '\0'; + } + } +} + +bool Filter::addChannel(const char* name) { + if (name == nullptr || name[0] == '\0') return false; + + for (int i=0; isecret, 0, PUB_KEY_SIZE); + if (strcmp(name, "Public") == 0) { + memcpy(gc->secret, PUBLIC_CHANNEL_SECRET, PUB_KEY_SIZE); + } else { + mesh::Utils::sha256(gc->secret, 16, (const uint8_t*)name, strlen(name)); + } + + // get channel hash + mesh::Utils::sha256(gc->hash, sizeof(gc->hash), gc->secret, 16); + return true; +} + +void Filter::listChannelNames(char *out_buf, size_t out_size) { + if (out_buf == nullptr || out_size == 0) return; + + out_buf[0] = '\0'; + size_t pos = 0; + char channel_hex[4]; + + for (int i=0; i 0 && pos + 1 < out_size) { + out_buf[pos++] = ','; + out_buf[pos] = '\0'; + } + + // remaining + size_t rem = out_size - pos; + if (rem == 0) break; + + // append name + mesh::Utils::toHex(channel_hex, _prefs.filter_channels[i].channel.hash, 1); + int written = snprintf(out_buf + pos, rem, "%s (%s)", name, channel_hex); + if (written < 0) break; + // if truncated, snprintf returns number that would have been written + size_t adv = (static_cast(written) < rem) ? static_cast(written) : rem - 1; + pos += adv; + out_buf[pos] = '\0'; + if (adv == rem - 1) break; // buffer full + } + + // no channels + if (!pos) strcpy(out_buf, "None"); +} + +bool Filter::validMessageContent(const uint8_t* data, uint8_t len) { + if (data == nullptr || len <= 5) return false; + + // check timestamp + uint32_t now = _rtc->getCurrentTime(); + uint32_t timestamp; + memcpy(×tamp, &data[0], 4); + if (!timestamp || timestamp < now - INVALID_TIMESTAMP_WINDOW || timestamp > now + INVALID_TIMESTAMP_WINDOW) return false; + + // check message type + uint8_t txt_type = data[4] >> 2; + if (txt_type != TXT_TYPE_PLAIN) return true; + + // calculate text length + uint8_t txt_len = 5; + while (txt_len < len && data[txt_len] != 0) txt_len++; + txt_len -= 5; + if (!txt_len) return false; + + // valid UTF8 + if (!isValidUTF8(&data[5], txt_len)) return false; + + // valid + return true; +} + +bool Filter::isValidUTF8(const uint8_t* data, uint8_t len) { + if (data == nullptr || len == 0) return false; + + uint8_t i = 0; + while (i < len) { + uint8_t c = data[i++]; + if (c == 0) break; + if (c < 0x80) continue; + + uint32_t codepoint; + uint8_t needed; + if ((c & 0xE0) == 0xC0) { + codepoint = c & 0x1F; + needed = 1; + if (codepoint == 0) return false; + } else if ((c & 0xF0) == 0xE0) { + codepoint = c & 0x0F; + needed = 2; + } else if ((c & 0xF8) == 0xF0) { + codepoint = c & 0x07; + needed = 3; + } else { + return false; + } + + if (i + needed > len) return false; + for (uint8_t j = 0; j < needed; j++) { + uint8_t cc = data[i++]; + if ((cc & 0xC0) != 0x80) return false; + codepoint = (codepoint << 6) | (cc & 0x3F); + } + + if (needed == 1 && codepoint < 0x80) return false; + if (needed == 2 && codepoint < 0x0800) return false; + if (needed == 3 && codepoint < 0x10000) return false; + if (codepoint > 0x10FFFF) return false; + if (codepoint >= 0xD800 && codepoint <= 0xDFFF) return false; + if (codepoint >= 0xFDD0 && codepoint <= 0xFDEF) return false; + if ((codepoint & 0xFFFE) == 0xFFFE) return false; + } + return true; +} + +bool Filter::load(FILESYSTEM* fs) { + if (fs == nullptr || !fs->exists(FILTER_PREFS_FILE)) return true; + +#if defined(RP2040_PLATFORM) + File file = fs->open(FILTER_PREFS_FILE, "r"); +#else + File file = fs->open(FILTER_PREFS_FILE); +#endif + + if (!file) return false; + + file.read(reinterpret_cast(&_prefs), sizeof(_prefs)); + + _prefs.filter_enabled = constrain(_prefs.filter_enabled, 0, 1); + + for (uint8_t i = 0; i < PAYLOAD_TYPE_COUNT; ++i) { + _prefs.payload_prefs[i].hops_max = constrain(_prefs.payload_prefs[i].hops_max, 0, 64); + _limiters[i].init(_prefs.payload_prefs[i].rate_limit, _prefs.payload_prefs[i].rate_secs); + } + _prefs.minimal_hash_bytes = constrain(_prefs.minimal_hash_bytes, 1, 3); + _prefs.filter_malformed = constrain(_prefs.filter_malformed, 0, 1); + + file.close(); + return true; +} + +bool Filter::save(FILESYSTEM* fs) const { + if (fs == nullptr) return false; + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs->remove(FILTER_PREFS_FILE); + File file = fs->open(FILTER_PREFS_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File file = fs->open(FILTER_PREFS_FILE, "w"); +#else + File file = fs->open(FILTER_PREFS_FILE, "w", true); +#endif + + if (!file) return false; + + file.write(reinterpret_cast(&_prefs), sizeof(_prefs)); + file.close(); + return true; +} \ No newline at end of file diff --git a/examples/simple_repeater/Filter.h b/examples/simple_repeater/Filter.h new file mode 100644 index 0000000000..ce37610f4b --- /dev/null +++ b/examples/simple_repeater/Filter.h @@ -0,0 +1,121 @@ +#pragma once + +#include "Mesh.h" + +#include +#include +#include + +#define FILTER_PREFS_FILE "/filter_prefs" +#define FILTER_CHANNEL_COUNT 16 + +static const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = { 0x8B, 0x33, 0x87, 0xE9, 0xC5, 0xCD, 0xEA, 0x6A, + 0xC9, 0xE5, 0xED, 0xBA, 0xA1, 0x15, 0xCD, 0x72, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 }; +static const uint8_t PUBLIC_CHANNEL_HASH = 0x11; +static const uint32_t INVALID_TIMESTAMP_WINDOW = (7 * 24 * 60 * 60); // 1 week +static const uint8_t PAYLOAD_TYPE_COUNT = 0x0C; + +struct ChannelDetails { + mesh::GroupChannel channel; + char name[32]; + + ChannelDetails() { + name[0] = '\0'; + memset(channel.hash, 0, sizeof(channel.hash)); + memset(channel.secret, 0, sizeof(channel.secret)); + } +}; + +struct PayloadPrefs { + uint8_t hops_max; + uint16_t rate_limit; + uint32_t rate_secs; +}; + +struct FilterPrefs { + uint8_t filter_enabled = false; + PayloadPrefs payload_prefs[PAYLOAD_TYPE_COUNT] = { + [PAYLOAD_TYPE_REQ] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_RESPONSE] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_TXT_MSG] = { .hops_max = 8, .rate_limit = 20, .rate_secs = 60 }, + [PAYLOAD_TYPE_ACK] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_ADVERT] = { .hops_max = 8, .rate_limit = 10, .rate_secs = 60 }, + [PAYLOAD_TYPE_GRP_TXT] = { .hops_max = 32, .rate_limit = 20, .rate_secs = 60 }, + [PAYLOAD_TYPE_GRP_DATA] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_ANON_REQ] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_PATH] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_TRACE] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_MULTIPART] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + [PAYLOAD_TYPE_CONTROL] = { .hops_max = 8, .rate_limit = 5, .rate_secs = 60 }, + }; + ChannelDetails filter_channels[FILTER_CHANNEL_COUNT]; + uint8_t minimal_hash_bytes = 1; + uint8_t filter_malformed = false; +}; + +struct Counters { + uint16_t hops[PAYLOAD_TYPE_COUNT] = {}; + uint16_t rate[PAYLOAD_TYPE_COUNT] = {}; + uint16_t channel = 0; + uint16_t hash = 0; + uint16_t malformed = 0; +}; + +class Limiter { + uint32_t _start; + uint32_t _secs; + uint16_t _limit, _count; + +public: + Limiter() : _limit(0), _secs(0), _start(0), _count(0) {} + + void init(uint16_t limit, uint32_t secs) { + _limit = limit; + _secs = secs; + _start = _count = 0; + } + + bool allow(uint32_t now) { + if (!_limit) return true; + if (now < _start + _secs) { + if (++_count > _limit) return false; + } else { + _start = now; + _count = 1; + } + return true; + } +}; + +class Filter { + ClientACL *_acl; + mesh::RTCClock *_rtc; + FilterPrefs _prefs; + Limiter _limiters[PAYLOAD_TYPE_COUNT]; + Counters _cnt; + +public: + enum ResponseType { + HOPS, + RATE, + COUNT + }; + + Filter(ClientACL &acl, mesh::RTCClock &rtc) : _acl(&acl), _rtc(&rtc) {} + void resetPrefs(void) { _prefs = FilterPrefs(); } + void resetStats(void) { _cnt = Counters(); } + bool allowPacketForward(const mesh::Packet *packet); + bool hasPriority(const mesh::Packet *packet); + void handleCommand(FILESYSTEM *fs, char *command, char *reply); + void formatResponse(char *reply, ResponseType rtype); + bool addChannel(const char *name); + bool removeChannel(const char *name); + static bool getChannelHash(const char *name, mesh::GroupChannel *gc); + void listChannelNames(char *out_buf, size_t out_size); + bool validMessageContent(const uint8_t *data, uint8_t len); + static bool isValidUTF8(const uint8_t *data, uint8_t len); + bool load(FILESYSTEM *fs); + bool save(FILESYSTEM *fs) const; +}; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 18484952d3..5e288cd602 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -490,7 +490,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return false; } } - return true; + + // additional filtering + return _filter.allowPacketForward(packet); } const char *MyMesh::getLogDateTime() { @@ -906,7 +908,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes - anon_limiter(4, 180) // max 4 every 3 minutes + anon_limiter(4, 180), // max 4 every 3 minutes + _filter(acl, rtc) #if defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) #elif defined(WITH_ESPNOW_BRIDGE) @@ -1026,6 +1029,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); + _filter.load(_fs); // establish default-scope { @@ -1326,6 +1330,7 @@ void MyMesh::clearStats() { radio_driver.resetStats(); resetStats(); ((SimpleMeshTables *)getTables())->resetStats(); + _filter.resetStats(); } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { @@ -1414,6 +1419,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } + } else if (memcmp(command, "filter", 6) == 0) { + _filter.handleCommand(_fs, command, reply); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 9e7ded6b38..9fc0241514 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -45,7 +45,7 @@ #include #include #include "RateLimiter.h" - +#include "Filter.h" struct RepeaterStats { uint16_t batt_milli_volts; @@ -111,6 +111,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + Filter _filter; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif