Files
HaloKeymind/examples/simple_room_server/FloodRuleEngine.cpp
T
mikecarper 11b692abfa Harden firmware recovery and retries
Improve companion BLE delivery and idempotent remote CLI retries, make FPF7 persistence atomic with FPF6 migration, verify staged OTA data on resume, and clarify filter policy wildcard and BlackHole semantics.
2026-08-07 16:24:53 -07:00

1694 lines
60 KiB
C++

#include "FloodRuleEngine.h"
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#include <stddef.h>
#include <string.h>
#include <helpers/FloodFilterPolicy.h>
#include <helpers/RegionNameUtils.h>
namespace {
static const char RULE_FILE[] = "/flood_filter";
static const char RULE_TEMP_FILE[] = "/flood_filter.tmp";
static const char RULE_BACKUP_FILE[] = "/flood_filter.bak";
static const char RULE_USAGE[] =
"Err - use: set flood.rule[.n] type=<type> [hops=<range>] [...]";
static const char DUPLICATE_OPTION[] = "Err - duplicate filter option";
// Channel encryption uses a 128-bit key, while MACThenDecrypt takes a
// PUB_KEY_SIZE buffer. Keep the unused half zero-padded like GroupChannel.
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
};
static const char* skipSpaces(const char* text) {
while (text != NULL && *text == ' ') text++;
return text == NULL ? "" : text;
}
static bool asciiEqual(const char* left, const char* right) {
if (left == NULL || right == NULL) return false;
while (*left && *right) {
char a = *left++;
char b = *right++;
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
if (a != b) return false;
}
return *left == 0 && *right == 0;
}
static bool asciiStartsWith(const char* text, const char* prefix) {
if (text == NULL || prefix == NULL) return false;
while (*prefix) {
char a = *text++;
char b = *prefix++;
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
if (a != b) return false;
}
return true;
}
static void copyString(char* dest, const char* src, size_t dest_len) {
if (dest == NULL || dest_len == 0) return;
if (src == NULL) src = "";
size_t len = strlen(src);
if (len >= dest_len) len = dest_len - 1;
memcpy(dest, src, len);
dest[len] = 0;
}
static bool parseUnsigned(const char* text, uint32_t maximum,
uint32_t& value) {
if (text == NULL || *text == 0) return false;
uint32_t parsed = 0;
for (const char* p = text; *p; p++) {
if (*p < '0' || *p > '9') return false;
uint32_t digit = (uint32_t)(*p - '0');
if (digit > maximum || parsed > (maximum - digit) / 10U) return false;
parsed = parsed * 10U + digit;
}
value = parsed;
return true;
}
static const char* payloadTypeName(uint8_t type) {
switch (type) {
case PAYLOAD_TYPE_REQ: return "req";
case PAYLOAD_TYPE_RESPONSE: return "response";
case PAYLOAD_TYPE_TXT_MSG: return "txt_msg";
case PAYLOAD_TYPE_ACK: return "ack";
case PAYLOAD_TYPE_ADVERT: return "advert";
case PAYLOAD_TYPE_GRP_TXT: return "grp_txt";
case PAYLOAD_TYPE_GRP_DATA: return "grp_data";
case PAYLOAD_TYPE_ANON_REQ: return "anon_req";
case PAYLOAD_TYPE_PATH: return "path";
case PAYLOAD_TYPE_TRACE: return "trace";
case PAYLOAD_TYPE_MULTIPART: return "multipart";
case PAYLOAD_TYPE_CONTROL: return "control";
case PAYLOAD_TYPE_OTA: return "ota";
case 0x0D: return "reserved13";
case 0x0E: return "reserved14";
case PAYLOAD_TYPE_RAW_CUSTOM: return "raw_custom";
case FloodRuleEngine::ANY_TYPE: return "any";
default: return "invalid";
}
}
static bool parsePayloadType(const char* text, uint8_t& type) {
uint32_t numeric = 0;
if (parseUnsigned(text, PH_TYPE_MASK, numeric)) {
type = (uint8_t)numeric;
return true;
}
if (text != NULL && text[0] == '0'
&& (text[1] == 'x' || text[1] == 'X') && text[2] != 0) {
uint8_t parsed = 0;
for (const char* p = text + 2; *p; p++) {
uint8_t digit;
if (*p >= '0' && *p <= '9') digit = (uint8_t)(*p - '0');
else if (*p >= 'a' && *p <= 'f') digit = (uint8_t)(*p - 'a' + 10);
else if (*p >= 'A' && *p <= 'F') digit = (uint8_t)(*p - 'A' + 10);
else return false;
if (parsed > (PH_TYPE_MASK - digit) / 16U) return false;
parsed = (uint8_t)(parsed * 16U + digit);
}
type = parsed;
return true;
}
if (asciiStartsWith(text, "payload_type_")) text += strlen("payload_type_");
if (asciiEqual(text, "any")) type = FloodRuleEngine::ANY_TYPE;
else if (asciiEqual(text, "req")) type = PAYLOAD_TYPE_REQ;
else if (asciiEqual(text, "response") || asciiEqual(text, "resp")) type = PAYLOAD_TYPE_RESPONSE;
else if (asciiEqual(text, "txt_msg") || asciiEqual(text, "txt")) type = PAYLOAD_TYPE_TXT_MSG;
else if (asciiEqual(text, "ack")) type = PAYLOAD_TYPE_ACK;
else if (asciiEqual(text, "advert")) type = PAYLOAD_TYPE_ADVERT;
else if (asciiEqual(text, "grp_txt") || asciiEqual(text, "group_text")) type = PAYLOAD_TYPE_GRP_TXT;
else if (asciiEqual(text, "grp_data") || asciiEqual(text, "group_data")) type = PAYLOAD_TYPE_GRP_DATA;
else if (asciiEqual(text, "anon_req")) type = PAYLOAD_TYPE_ANON_REQ;
else if (asciiEqual(text, "path")) type = PAYLOAD_TYPE_PATH;
else if (asciiEqual(text, "trace")) type = PAYLOAD_TYPE_TRACE;
else if (asciiEqual(text, "multipart")) type = PAYLOAD_TYPE_MULTIPART;
else if (asciiEqual(text, "control")) type = PAYLOAD_TYPE_CONTROL;
else if (asciiEqual(text, "ota")) type = PAYLOAD_TYPE_OTA;
else if (asciiEqual(text, "raw") || asciiEqual(text, "raw_custom")) type = PAYLOAD_TYPE_RAW_CUSTOM;
else return false;
return true;
}
static bool parseHopSpec(const char* text, uint8_t& min_hops,
uint8_t& max_hops) {
if (text == NULL || *text == 0) return false;
if (asciiEqual(text, "all")) {
min_hops = 0;
max_hops = FloodRuleEngine::MAX_HOPS;
return true;
}
char spec[12];
if (strlen(text) >= sizeof(spec)) return false;
copyString(spec, text, sizeof(spec));
size_t len = strlen(spec);
uint32_t parsed_min = 0;
uint32_t parsed_max = 0;
if (len > 1 && spec[len - 1] == '+') {
spec[len - 1] = 0;
if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min)) return false;
min_hops = (uint8_t)parsed_min;
max_hops = FloodRuleEngine::MAX_HOPS;
return true;
}
char* dash = strchr(spec, '-');
if (dash != NULL) {
*dash++ = 0;
if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min)
|| !parseUnsigned(dash, FloodRuleEngine::MAX_HOPS, parsed_max)
|| parsed_min > parsed_max) {
return false;
}
min_hops = (uint8_t)parsed_min;
max_hops = (uint8_t)parsed_max;
return true;
}
if (!parseUnsigned(spec, FloodRuleEngine::MAX_HOPS, parsed_min)) return false;
min_hops = max_hops = (uint8_t)parsed_min;
return true;
}
static void formatHopSpec(char* dest, size_t dest_len, uint8_t min_hops,
uint8_t max_hops) {
if (min_hops == 0 && max_hops == FloodRuleEngine::MAX_HOPS) {
snprintf(dest, dest_len, "all");
} else if (max_hops == FloodRuleEngine::MAX_HOPS) {
snprintf(dest, dest_len, "%u+", (unsigned int)min_hops);
} else if (min_hops == max_hops) {
snprintf(dest, dest_len, "%u", (unsigned int)min_hops);
} else {
snprintf(dest, dest_len, "%u-%u", (unsigned int)min_hops,
(unsigned int)max_hops);
}
}
static bool normalizeScopeName(const char* text, char* dest,
size_t dest_len) {
if (text == NULL || dest == NULL || dest_len < 3) return false;
if (*text == '#') text++;
if (*text == 0 || *text == '$') return false;
size_t len = 0;
while (text[len]) {
uint8_t c = (uint8_t)text[len];
if (!RegionMap::is_name_char(c) || c == '#' || c == '$') return false;
len++;
}
if (len + 2 > dest_len) return false;
dest[0] = '#';
memcpy(&dest[1], text, len + 1);
return true;
}
static bool validStoredScopeName(const char* text) {
if (text == NULL || text[0] != '#' || text[1] == 0) return false;
for (size_t i = 1; i < FloodRuleEngine::NAME_LEN; i++) {
uint8_t c = (uint8_t)text[i];
if (c == 0) return true;
if (!RegionMap::is_name_char(c) || c == '#' || c == '$') return false;
}
return false;
}
static bool validStoredRegionName(const char* text) {
if (text == NULL || text[0] == 0 || strcmp(text, "*") == 0) return false;
for (size_t i = 0; i < FloodRuleEngine::NAME_LEN; i++) {
uint8_t c = (uint8_t)text[i];
if (c == 0) return true;
if (!RegionMap::is_name_char(c)) return false;
}
return false;
}
static void deriveScopeKey(const char* scope_name, TransportKey& scope) {
mesh::Utils::sha256(scope.key, sizeof(scope.key),
(const uint8_t*)scope_name, strlen(scope_name));
}
static bool parseChannel(const char* text, uint8_t secret[PUB_KEY_SIZE],
uint8_t& key_len, uint8_t& channel_hash,
char* name, size_t name_len) {
if (text == NULL || *text == 0) return false;
memset(secret, 0, PUB_KEY_SIZE);
if (asciiEqual(text, "public")) {
key_len = CIPHER_KEY_SIZE;
memcpy(secret, PUBLIC_CHANNEL_SECRET, sizeof(PUBLIC_CHANNEL_SECRET));
copyString(name, "public", name_len);
} else if (text[0] == '#' && text[1] != 0) {
if (strlen(text) >= name_len) return false;
key_len = CIPHER_KEY_SIZE;
mesh::Utils::sha256(secret, key_len, (const uint8_t*)text,
strlen(text));
copyString(name, text, name_len);
} else {
size_t hex_len = strlen(text);
if (hex_len != CIPHER_KEY_SIZE * 2 && hex_len != PUB_KEY_SIZE * 2) {
return false;
}
for (size_t i = 0; i < hex_len; i++) {
if (!mesh::Utils::isHexChar(text[i])) return false;
}
key_len = (uint8_t)(hex_len / 2);
if (!mesh::Utils::fromHex(secret, key_len, text)) return false;
uint8_t prefix[4];
mesh::Utils::sha256(prefix, sizeof(prefix), secret, key_len);
char prefix_hex[9];
mesh::Utils::toHex(prefix_hex, prefix, sizeof(prefix));
snprintf(name, name_len, "key:%s", prefix_hex);
}
mesh::Utils::sha256(&channel_hash, sizeof(channel_hash), secret, key_len);
return true;
}
static bool parsePathPrefix(const char* text, uint8_t& hash_size,
uint8_t& path_hops,
uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) {
memset(path, 0, FloodRuleEngine::PATH_PREFIX_BYTES_MAX);
if (text == NULL || *text == 0 || strcmp(text, "*") == 0) {
hash_size = 0;
path_hops = 0;
return text != NULL && *text != 0;
}
if (strlen(text) >= 32) return false;
char input[32];
copyString(input, text, sizeof(input));
char* token = input;
uint8_t parsed_size = 0;
uint8_t count = 0;
while (token != NULL) {
char* comma = strchr(token, ',');
if (comma != NULL) *comma = 0;
size_t hex_len = strlen(token);
if (hex_len != 2 && hex_len != 4 && hex_len != 6) return false;
uint8_t token_size = (uint8_t)(hex_len / 2);
if ((parsed_size != 0 && parsed_size != token_size)
|| count >= FloodRuleEngine::PATH_PREFIX_HOPS_MAX) {
return false;
}
for (size_t i = 0; i < hex_len; i++) {
if (!mesh::Utils::isHexChar(token[i])) return false;
}
parsed_size = token_size;
if (!mesh::Utils::fromHex(&path[count * parsed_size], parsed_size,
token)) {
return false;
}
count++;
token = comma == NULL ? NULL : comma + 1;
}
if (count == 0) return false;
hash_size = parsed_size;
path_hops = count;
return true;
}
static void formatPathPrefix(
char* dest, size_t dest_len, uint8_t hash_size, uint8_t path_hops,
const uint8_t path[FloodRuleEngine::PATH_PREFIX_BYTES_MAX]) {
if (hash_size == 0 || path_hops == 0) {
copyString(dest, "*", dest_len);
return;
}
size_t used = 0;
dest[0] = 0;
for (uint8_t i = 0; i < path_hops; i++) {
char hop[7];
mesh::Utils::toHex(hop, &path[i * hash_size], hash_size);
int written = snprintf(&dest[used], dest_len - used, "%s%s",
i == 0 ? "" : ",", hop);
if (written < 0 || (size_t)written >= dest_len - used) {
dest[dest_len - 1] = 0;
return;
}
used += (size_t)written;
}
}
static bool commandMatches(const char* command, const char* base) {
size_t len = strlen(base);
return strncmp(command, base, len) == 0
&& (command[len] == 0 || command[len] == '.' || command[len] == ' ');
}
static File openRead(FILESYSTEM* fs, const char* path) {
return fs->open(path);
}
static File openWrite(FILESYSTEM* fs, const char* path) {
return fs->open(path, "w", true);
}
static uint32_t updateFileHash(uint32_t hash,
const uint8_t* data, size_t len) {
while (len-- > 0) {
hash ^= *data++;
hash *= 16777619UL;
}
return hash;
}
static bool verifyWrittenFile(FILESYSTEM* fs, const char* path,
size_t expected_size,
uint32_t expected_hash) {
File file = openRead(fs, path);
if (!file || file.size() != expected_size) {
if (file) file.close();
return false;
}
uint32_t hash = 2166136261UL;
uint8_t buffer[64];
size_t remaining = expected_size;
while (remaining > 0) {
size_t amount = remaining < sizeof(buffer) ? remaining : sizeof(buffer);
if (file.read(buffer, amount) != amount) {
file.close();
return false;
}
hash = updateFileHash(hash, buffer, amount);
remaining -= amount;
}
file.close();
return hash == expected_hash;
}
} // namespace
FloodRuleEngine::FloodRuleEngine() : _fs(NULL), _regions(NULL) {
memset(_entries, 0, sizeof(_entries));
}
void FloodRuleEngine::begin(FILESYSTEM* fs, RegionMap* regions) {
_fs = fs;
_regions = regions;
load();
}
void FloodRuleEngine::seedDefaults() {
memset(_entries, 0, sizeof(_entries));
Entry& entry = _entries[0];
entry.active = true;
entry.payload_type = PAYLOAD_TYPE_OTA;
entry.min_hops = 0;
entry.max_hops = MAX_HOPS;
entry.suspend_on_temp_radio = true;
entry.drop_on_match = true;
}
void FloodRuleEngine::load() {
if (_fs == NULL) {
seedDefaults();
return;
}
enum class FileState : uint8_t { Missing, Valid, Invalid, Unreadable };
auto loadFile = [this](const char* path) -> FileState {
memset(_entries, 0, sizeof(_entries));
if (!_fs->exists(path)) return FileState::Missing;
File file = openRead(_fs, path);
if (!file) return FileState::Unreadable;
Entry* loaded = _entries;
auto readExact = [&file](void* dest, size_t len) {
return file.read((uint8_t*)dest, len) == len;
};
uint8_t magic[4];
uint8_t count = 0;
bool success = readExact(magic, sizeof(magic));
bool version_6 = success && memcmp(magic, "FPF6", 4) == 0;
bool version_7 = success && memcmp(magic, "FPF7", 4) == 0;
success = (version_6 || version_7)
&& readExact(&count, sizeof(count)) && count <= RULE_SLOTS;
for (int i = 0; success && i < count; i++) {
uint8_t active = 0;
uint8_t suspend_on_temp_radio = 0;
uint8_t match_blacklisted_path = 0;
uint8_t scope_requires_region_match = 0;
uint8_t scope_uses_slow_timing = 0;
uint8_t drop_on_match = 0;
uint8_t rate_limit_enabled = 0;
uint8_t stop_on_match = 0;
success = readExact(&active, sizeof(active))
&& readExact(&loaded[i].payload_type,
sizeof(loaded[i].payload_type))
&& readExact(&loaded[i].min_hops, sizeof(loaded[i].min_hops))
&& readExact(&loaded[i].max_hops, sizeof(loaded[i].max_hops))
&& readExact(&suspend_on_temp_radio,
sizeof(suspend_on_temp_radio))
&& readExact(loaded[i].scope_name, sizeof(loaded[i].scope_name))
&& readExact(&match_blacklisted_path,
sizeof(match_blacklisted_path))
&& readExact(&scope_requires_region_match,
sizeof(scope_requires_region_match))
&& readExact(&scope_uses_slow_timing,
sizeof(scope_uses_slow_timing));
if (success && version_7) {
success = readExact(&loaded[i].incoming_scope_kind,
sizeof(loaded[i].incoming_scope_kind))
&& readExact(loaded[i].incoming_scope_name,
sizeof(loaded[i].incoming_scope_name))
&& readExact(&loaded[i].channel_key_len,
sizeof(loaded[i].channel_key_len))
&& readExact(loaded[i].channel_secret,
sizeof(loaded[i].channel_secret))
&& readExact(loaded[i].channel_name,
sizeof(loaded[i].channel_name))
&& readExact(&loaded[i].path_hash_size,
sizeof(loaded[i].path_hash_size))
&& readExact(&loaded[i].path_hops,
sizeof(loaded[i].path_hops))
&& readExact(loaded[i].path, sizeof(loaded[i].path))
&& readExact(&drop_on_match, sizeof(drop_on_match))
&& readExact(&rate_limit_enabled, sizeof(rate_limit_enabled))
&& readExact(&loaded[i].rate_per_minute,
sizeof(loaded[i].rate_per_minute))
&& readExact(loaded[i].target_region_name,
sizeof(loaded[i].target_region_name))
&& readExact(&loaded[i].priority, sizeof(loaded[i].priority))
&& readExact(&stop_on_match, sizeof(stop_on_match));
} else {
loaded[i].incoming_scope_kind = scope_requires_region_match
? FloodFilterPolicy::RULE_IN_ALLOWED
: FloodFilterPolicy::RULE_IN_ANY;
drop_on_match = loaded[i].scope_name[0] == 0 ? 1 : 0;
}
loaded[i].active = active != 0;
loaded[i].suspend_on_temp_radio = suspend_on_temp_radio != 0;
loaded[i].match_blacklisted_path = match_blacklisted_path != 0;
loaded[i].scope_uses_slow_timing = scope_uses_slow_timing != 0;
loaded[i].drop_on_match = drop_on_match != 0;
loaded[i].rate_limit_enabled = rate_limit_enabled != 0;
loaded[i].stop_on_match = stop_on_match != 0;
if (success && (active > 1 || suspend_on_temp_radio > 1
|| match_blacklisted_path > 1 || scope_requires_region_match > 1
|| scope_uses_slow_timing > 1 || drop_on_match > 1
|| rate_limit_enabled > 1 || stop_on_match > 1)) {
success = false;
}
if (!success) break;
if (!loaded[i].active) {
memset(&loaded[i], 0, sizeof(loaded[i]));
continue;
}
bool scope_name_terminated = memchr(
loaded[i].scope_name, 0, sizeof(loaded[i].scope_name)) != NULL;
bool input_name_terminated = memchr(
loaded[i].incoming_scope_name, 0,
sizeof(loaded[i].incoming_scope_name)) != NULL;
bool channel_name_terminated = memchr(
loaded[i].channel_name, 0,
sizeof(loaded[i].channel_name)) != NULL;
bool target_name_terminated = memchr(
loaded[i].target_region_name, 0,
sizeof(loaded[i].target_region_name)) != NULL;
bool direct_target = scope_name_terminated
&& loaded[i].scope_name[0] != 0;
bool region_target = target_name_terminated
&& loaded[i].target_region_name[0] != 0;
bool incoming_valid = loaded[i].incoming_scope_kind
<= FloodFilterPolicy::RULE_IN_REGION;
if (incoming_valid
&& loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) {
incoming_valid = input_name_terminated
&& validStoredScopeName(loaded[i].incoming_scope_name);
} else if (incoming_valid
&& loaded[i].incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) {
incoming_valid = input_name_terminated
&& validStoredRegionName(loaded[i].incoming_scope_name);
} else if (incoming_valid) {
incoming_valid = input_name_terminated
&& loaded[i].incoming_scope_name[0] == 0;
}
bool channel_valid = loaded[i].channel_key_len == 0
|| loaded[i].channel_key_len == CIPHER_KEY_SIZE
|| loaded[i].channel_key_len == PUB_KEY_SIZE;
if (channel_valid && loaded[i].channel_key_len == 0) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] == 0;
} else if (channel_valid) {
channel_valid = channel_name_terminated
&& loaded[i].channel_name[0] != 0;
if (channel_valid) {
mesh::Utils::sha256(&loaded[i].channel_hash,
sizeof(loaded[i].channel_hash),
loaded[i].channel_secret,
loaded[i].channel_key_len);
}
}
if (channel_valid && loaded[i].channel_key_len != 0) {
channel_valid = loaded[i].payload_type == ANY_TYPE
|| loaded[i].payload_type == PAYLOAD_TYPE_GRP_TXT
|| loaded[i].payload_type == PAYLOAD_TYPE_GRP_DATA;
}
bool path_valid = (loaded[i].path_hash_size == 0
&& loaded[i].path_hops == 0)
|| (!loaded[i].match_blacklisted_path
&& loaded[i].path_hash_size >= 1
&& loaded[i].path_hash_size <= 3
&& loaded[i].path_hops >= 1
&& loaded[i].path_hops <= PATH_PREFIX_HOPS_MAX);
bool action_valid = loaded[i].drop_on_match || direct_target
|| region_target || loaded[i].rate_limit_enabled
|| loaded[i].stop_on_match;
if (!((loaded[i].payload_type <= PH_TYPE_MASK
|| loaded[i].payload_type == ANY_TYPE)
&& loaded[i].min_hops <= loaded[i].max_hops
&& loaded[i].max_hops <= MAX_HOPS
&& scope_name_terminated
&& (!direct_target
|| validStoredScopeName(loaded[i].scope_name))
&& target_name_terminated
&& (!region_target
|| validStoredRegionName(loaded[i].target_region_name))
&& !(direct_target && region_target)
&& !(loaded[i].drop_on_match
&& (direct_target || region_target))
&& !(loaded[i].drop_on_match
&& loaded[i].rate_limit_enabled)
&& (!loaded[i].rate_limit_enabled
|| loaded[i].rate_per_minute < RATE_UNLIMITED)
&& (!loaded[i].scope_uses_slow_timing
|| direct_target || region_target)
&& incoming_valid && channel_valid && path_valid
&& action_valid)) {
success = false;
}
}
file.close();
if (!success) memset(_entries, 0, sizeof(_entries));
return success ? FileState::Valid : FileState::Invalid;
};
FileState primary = loadFile(RULE_FILE);
if (primary == FileState::Valid) {
if (_fs->exists(RULE_TEMP_FILE)) _fs->remove(RULE_TEMP_FILE);
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
return;
}
if (primary == FileState::Unreadable) {
return;
}
FileState temp = loadFile(RULE_TEMP_FILE);
if (temp == FileState::Valid) {
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (!_fs->exists(RULE_FILE)
&& _fs->rename(RULE_TEMP_FILE, RULE_FILE)) {
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
}
}
return;
}
FileState backup = loadFile(RULE_BACKUP_FILE);
if (backup == FileState::Valid) {
if (primary != FileState::Unreadable) {
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (!_fs->exists(RULE_FILE)
&& _fs->rename(RULE_BACKUP_FILE, RULE_FILE)
&& temp != FileState::Unreadable
&& _fs->exists(RULE_TEMP_FILE)) {
_fs->remove(RULE_TEMP_FILE);
}
}
return;
}
seedDefaults();
if (primary == FileState::Invalid) _fs->remove(RULE_FILE);
if (temp == FileState::Invalid) _fs->remove(RULE_TEMP_FILE);
if (backup == FileState::Invalid) _fs->remove(RULE_BACKUP_FILE);
if (primary != FileState::Unreadable && temp != FileState::Unreadable
&& backup != FileState::Unreadable) {
save();
}
}
bool FloodRuleEngine::save() {
if (_fs == NULL) return false;
if (_fs->exists(RULE_TEMP_FILE) || _fs->exists(RULE_BACKUP_FILE))
return false;
File file = openWrite(_fs, RULE_TEMP_FILE);
if (!file) return false;
size_t bytes_written = 0;
uint32_t write_hash = 2166136261UL;
auto writeExact = [&file, &bytes_written, &write_hash](
const void* src, size_t len) {
const uint8_t* data = (const uint8_t*)src;
if (file.write(data, len) != len) return false;
bytes_written += len;
write_hash = updateFileHash(write_hash, data, len);
return true;
};
const uint8_t magic[4] = {'F', 'P', 'F', '7'};
uint8_t count = RULE_SLOTS;
bool success = writeExact(magic, sizeof(magic))
&& writeExact(&count, sizeof(count));
for (int i = 0; success && i < RULE_SLOTS; i++) {
const Entry& entry = _entries[i];
uint8_t active = entry.active ? 1 : 0;
uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0;
uint8_t match_blacklisted_path = entry.match_blacklisted_path ? 1 : 0;
uint8_t scope_requires_region_match =
entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ALLOWED
? 1 : 0;
uint8_t scope_uses_slow_timing = entry.scope_uses_slow_timing ? 1 : 0;
uint8_t drop_on_match = entry.drop_on_match ? 1 : 0;
uint8_t rate_limit_enabled = entry.rate_limit_enabled ? 1 : 0;
uint8_t stop_on_match = entry.stop_on_match ? 1 : 0;
success = writeExact(&active, sizeof(active))
&& writeExact(&entry.payload_type, sizeof(entry.payload_type))
&& writeExact(&entry.min_hops, sizeof(entry.min_hops))
&& writeExact(&entry.max_hops, sizeof(entry.max_hops))
&& writeExact(&suspend_on_temp_radio,
sizeof(suspend_on_temp_radio))
&& writeExact(entry.scope_name, sizeof(entry.scope_name))
&& writeExact(&match_blacklisted_path,
sizeof(match_blacklisted_path))
&& writeExact(&scope_requires_region_match,
sizeof(scope_requires_region_match))
&& writeExact(&scope_uses_slow_timing,
sizeof(scope_uses_slow_timing))
&& writeExact(&entry.incoming_scope_kind,
sizeof(entry.incoming_scope_kind))
&& writeExact(entry.incoming_scope_name,
sizeof(entry.incoming_scope_name))
&& writeExact(&entry.channel_key_len, sizeof(entry.channel_key_len))
&& writeExact(entry.channel_secret, sizeof(entry.channel_secret))
&& writeExact(entry.channel_name, sizeof(entry.channel_name))
&& writeExact(&entry.path_hash_size, sizeof(entry.path_hash_size))
&& writeExact(&entry.path_hops, sizeof(entry.path_hops))
&& writeExact(entry.path, sizeof(entry.path))
&& writeExact(&drop_on_match, sizeof(drop_on_match))
&& writeExact(&rate_limit_enabled, sizeof(rate_limit_enabled))
&& writeExact(&entry.rate_per_minute,
sizeof(entry.rate_per_minute))
&& writeExact(entry.target_region_name,
sizeof(entry.target_region_name))
&& writeExact(&entry.priority, sizeof(entry.priority))
&& writeExact(&stop_on_match, sizeof(stop_on_match));
}
file.close();
if (!success || !verifyWrittenFile(
_fs, RULE_TEMP_FILE, bytes_written, write_hash)) {
_fs->remove(RULE_TEMP_FILE);
return false;
}
if (_fs->exists(RULE_FILE)
&& !_fs->rename(RULE_FILE, RULE_BACKUP_FILE)) {
_fs->remove(RULE_TEMP_FILE);
return false;
}
if (!_fs->rename(RULE_TEMP_FILE, RULE_FILE)) {
return false;
}
if (_fs->exists(RULE_BACKUP_FILE)) _fs->remove(RULE_BACKUP_FILE);
return true;
}
bool FloodRuleEngine::fieldsMatch(
const Entry& entry, const mesh::Packet* packet,
bool temp_radio_active, bool incoming_is_scoped,
uint16_t incoming_transport_code, bool incoming_region_allowed,
const RegionEntry* incoming_region) const {
if (!entry.active || packet == NULL || !packet->isRouteFlood()) return false;
if (entry.suspend_on_temp_radio && temp_radio_active) return false;
// FULL room servers do not carry the repeater's separate passive blacklist.
// Legacy blacklist-qualified rows stay inert instead of widening their match.
if (entry.match_blacklisted_path) return false;
if (!FloodFilterPolicy::pathStartsWith(
packet, entry.path_hash_size, entry.path_hops, entry.path)) {
return false;
}
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
if ((entry.payload_type != ANY_TYPE && entry.payload_type != type)
|| hops < entry.min_hops || hops > entry.max_hops) {
return false;
}
if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_REGION) {
if (incoming_region == NULL
|| !RegionNameUtils::equivalent(
entry.incoming_scope_name, incoming_region->name)) {
return false;
}
} else {
uint16_t wanted_transport_code = 0;
if (entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_SCOPE) {
TransportKey incoming_scope;
deriveScopeKey(entry.incoming_scope_name, incoming_scope);
wanted_transport_code = incoming_scope.calcTransportCode(packet);
}
if (!FloodFilterPolicy::ruleIncomingScopeMatches(
entry.incoming_scope_kind, incoming_is_scoped,
incoming_transport_code, incoming_region_allowed,
wanted_transport_code)) {
return false;
}
}
if (entry.channel_key_len != 0) {
if ((type != PAYLOAD_TYPE_GRP_TXT && type != PAYLOAD_TYPE_GRP_DATA)
|| packet->payload_len
< PATH_HASH_SIZE + CIPHER_MAC_SIZE + CIPHER_BLOCK_SIZE
|| ((packet->payload_len - PATH_HASH_SIZE - CIPHER_MAC_SIZE)
% CIPHER_BLOCK_SIZE) != 0
|| packet->payload[0] != entry.channel_hash) {
return false;
}
}
return true;
}
bool FloodRuleEngine::authenticateChannel(
const Entry& entry, const mesh::Packet* packet) const {
if (entry.channel_key_len == 0) return true;
uint8_t data[MAX_PACKET_PAYLOAD];
return mesh::Utils::MACThenDecrypt(
entry.channel_secret, data, &packet->payload[PATH_HASH_SIZE],
packet->payload_len - PATH_HASH_SIZE) > 0;
}
int FloodRuleEngine::nextMatch(uint32_t match_mask,
uint32_t visited_mask) const {
uint8_t priorities[RULE_SLOTS];
for (int i = 0; i < RULE_SLOTS; i++) {
priorities[i] = _entries[i].priority;
}
return FloodFilterPolicy::nextOrderedRule(
match_mask, visited_mask, priorities, RULE_SLOTS);
}
bool FloodRuleEngine::resolveTargetRegion(
const char* name, TransportKey& scope,
const char*& canonical_name) {
if (_regions == NULL || name == NULL || name[0] == 0) return false;
RegionEntry* region = _regions->findByName(name);
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| _regions->getTransportKeysFor(*region, &scope, 1) <= 0
|| scope.isNull()) {
return false;
}
canonical_name = region->name;
return true;
}
uint32_t FloodRuleEngine::applyStop(uint32_t match_mask) {
uint8_t priorities[RULE_SLOTS];
uint8_t stop_flags[RULE_SLOTS];
for (int i = 0; i < RULE_SLOTS; i++) {
priorities[i] = _entries[i].priority;
const Entry& entry = _entries[i];
bool region_usable = true;
if (entry.target_region_name[0] != 0) {
TransportKey scope;
const char* canonical_name = NULL;
region_usable = resolveTargetRegion(
entry.target_region_name, scope, canonical_name);
}
stop_flags[i] = FloodFilterPolicy::stopActionApplies(
entry.stop_on_match, entry.target_region_name[0] != 0,
region_usable) ? 1 : 0;
}
return FloodFilterPolicy::truncateRulesAtStop(
match_mask, priorities, stop_flags, RULE_SLOTS);
}
uint32_t FloodRuleEngine::evaluate(
const mesh::Packet* packet, bool temp_radio_active,
bool incoming_region_allowed,
const RegionEntry* incoming_region) {
static_assert(RULE_SLOTS <= 32,
"flood rule match mask supports at most 32 slots");
if (packet == NULL || !packet->isRouteFlood()) return 0;
bool incoming_is_scoped =
packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD;
uint16_t incoming_transport_code = incoming_is_scoped
? packet->transport_codes[0] : 0;
bool channel_auth_checked[RULE_SLOTS] = { false };
bool channel_auth_valid[RULE_SLOTS] = { false };
uint32_t result = 0;
for (int i = 0; i < RULE_SLOTS; i++) {
const Entry& entry = _entries[i];
if (!fieldsMatch(entry, packet, temp_radio_active, incoming_is_scoped,
incoming_transport_code, incoming_region_allowed,
incoming_region)) {
continue;
}
bool authenticated = true;
if (entry.channel_key_len != 0) {
int cached = -1;
for (int j = 0; j < i; j++) {
if (channel_auth_checked[j]
&& FloodFilterPolicy::sameChannelKey(
_entries[j].channel_key_len, _entries[j].channel_secret,
entry.channel_key_len, entry.channel_secret)) {
cached = j;
break;
}
}
authenticated = cached >= 0
? channel_auth_valid[cached]
: authenticateChannel(entry, packet);
channel_auth_checked[i] = true;
channel_auth_valid[i] = authenticated;
}
if (authenticated) result |= (uint32_t)1U << i;
}
return applyStop(result);
}
bool FloodRuleEngine::applyScope(mesh::Packet* packet, uint32_t match_mask,
bool& scope_set, bool& fast_track,
bool log_change) {
scope_set = false;
fast_track = false;
if (packet == NULL || !packet->isRouteFlood()) return false;
uint32_t visited = 0;
while (true) {
int i = nextMatch(match_mask, visited);
if (i < 0) break;
visited |= (uint32_t)1U << i;
const Entry& entry = _entries[i];
if (entry.scope_name[0] == 0
&& entry.target_region_name[0] == 0) {
continue;
}
TransportKey scope;
const char* target_name = entry.scope_name;
if (entry.scope_name[0] != 0) {
deriveScopeKey(entry.scope_name, scope);
} else {
if (!resolveTargetRegion(
entry.target_region_name, scope, target_name)) {
continue;
}
}
bool changed = FloodFilterPolicy::setTransportScope(
packet, scope.calcTransportCode(packet));
scope_set = true;
fast_track = FloodFilterPolicy::fastTrackScopeChange(
changed, entry.scope_uses_slow_timing);
if (changed && log_change) {
MESH_DEBUG_PRINTLN(
"flood.rule set scope slot=%d type=%d hops=%d scope=%s tx=%s",
i + 1, packet->getPayloadType(), packet->getPathHashCount(),
target_name, entry.scope_uses_slow_timing ? "slow" : "fast");
}
return changed;
}
return false;
}
bool FloodRuleEngine::shouldBlock(const mesh::Packet* packet,
uint32_t match_mask,
uint32_t now_millis) const {
if (packet == NULL || !packet->isRouteFlood()) return false;
uint8_t type = packet->getPayloadType();
uint8_t hops = packet->getPathHashCount();
uint32_t visited = 0;
while (true) {
int i = nextMatch(match_mask, visited);
if (i < 0) break;
visited |= (uint32_t)1U << i;
const Entry& entry = _entries[i];
bool blocked = entry.drop_on_match;
if (entry.rate_limit_enabled
&& FloodFilterPolicy::rateLimitReached(
entry.rate_window_active, now_millis,
entry.rate_window_started, entry.rate_window_count,
entry.rate_per_minute)) {
blocked = true;
}
if (blocked) {
MESH_DEBUG_PRINTLN(
"allowPacketForward: flood.rule matched slot=%d type=%d hops=%d",
i + 1, type, hops);
return true;
}
}
return false;
}
void FloodRuleEngine::commitRates(const mesh::Packet* packet,
uint32_t match_mask,
uint32_t now_millis) {
if (packet == NULL || !packet->isRouteFlood()) return;
for (int i = 0; i < RULE_SLOTS; i++) {
Entry& entry = _entries[i];
if ((match_mask & ((uint32_t)1U << i)) == 0
|| !entry.rate_limit_enabled) {
continue;
}
if (!entry.rate_window_active
|| now_millis - entry.rate_window_started >= 60000UL) {
entry.rate_window_active = true;
entry.rate_window_started = now_millis;
entry.rate_window_count = 0;
}
if (entry.rate_window_count < 0xFFFF) entry.rate_window_count++;
}
}
void FloodRuleEngine::formatDetail(int index, char* reply,
size_t reply_len) const {
if (index < 0 || index >= RULE_SLOTS || !_entries[index].active) {
snprintf(reply, reply_len, "Err - empty filter slot");
return;
}
const Entry& entry = _entries[index];
char hops[12];
char prefix[32];
char incoming[48];
char action[72];
char rate[24];
formatHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.match_blacklisted_path) copyString(prefix, "blacklist", sizeof(prefix));
else formatPathPrefix(prefix, sizeof(prefix), entry.path_hash_size,
entry.path_hops, entry.path);
switch (entry.incoming_scope_kind) {
case FloodFilterPolicy::RULE_IN_NONE:
copyString(incoming, "none", sizeof(incoming));
break;
case FloodFilterPolicy::RULE_IN_SCOPED:
copyString(incoming, "scoped", sizeof(incoming));
break;
case FloodFilterPolicy::RULE_IN_ALLOWED:
copyString(incoming, "allowed", sizeof(incoming));
break;
case FloodFilterPolicy::RULE_IN_UNKNOWN:
copyString(incoming, "unknown", sizeof(incoming));
break;
case FloodFilterPolicy::RULE_IN_SCOPE:
snprintf(incoming, sizeof(incoming), "scope:%s",
entry.incoming_scope_name);
break;
case FloodFilterPolicy::RULE_IN_REGION:
snprintf(incoming, sizeof(incoming), "region:%s%s",
entry.incoming_scope_name,
_regions != NULL
&& _regions->findByName(entry.incoming_scope_name) != NULL
? "" : "?");
break;
default:
copyString(incoming, "any", sizeof(incoming));
break;
}
action[0] = 0;
if (entry.drop_on_match) {
copyString(action, "drop", sizeof(action));
} else if (entry.scope_name[0] != 0) {
snprintf(action, sizeof(action), "scope=%s", entry.scope_name);
} else if (entry.target_region_name[0] != 0) {
RegionEntry* current = _regions == NULL ? NULL
: _regions->findByName(entry.target_region_name);
snprintf(action, sizeof(action), "region=%s%s",
entry.target_region_name, current == NULL ? "?" : "");
}
rate[0] = 0;
if (entry.rate_limit_enabled) {
snprintf(rate, sizeof(rate), "%srate=%u/min",
action[0] == 0 ? "" : " ",
(unsigned int)entry.rate_per_minute);
}
int written = snprintf(
reply, reply_len,
"> %d type=%s hops=%s channel=%s prefix=%s in=%s %s%s priority=%u%s%s%s",
index + 1, payloadTypeName(entry.payload_type), hops,
entry.channel_key_len == 0 ? "*" : entry.channel_name,
prefix, incoming, action, rate,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
entry.scope_uses_slow_timing ? " tx=slow" : "",
entry.suspend_on_temp_radio ? " suspend=tempradio" : "");
if (written >= 0 && (size_t)written < reply_len) return;
char compact_type[4];
if (entry.payload_type == ANY_TYPE) copyString(compact_type, "any", sizeof(compact_type));
else snprintf(compact_type, sizeof(compact_type), "%u",
(unsigned int)entry.payload_type);
char compact_incoming[44];
switch (entry.incoming_scope_kind) {
case FloodFilterPolicy::RULE_IN_NONE:
copyString(compact_incoming, "n", sizeof(compact_incoming));
break;
case FloodFilterPolicy::RULE_IN_SCOPED:
copyString(compact_incoming, "s", sizeof(compact_incoming));
break;
case FloodFilterPolicy::RULE_IN_ALLOWED:
copyString(compact_incoming, "a", sizeof(compact_incoming));
break;
case FloodFilterPolicy::RULE_IN_UNKNOWN:
copyString(compact_incoming, "u", sizeof(compact_incoming));
break;
case FloodFilterPolicy::RULE_IN_SCOPE:
snprintf(compact_incoming, sizeof(compact_incoming), "s:%s",
entry.incoming_scope_name);
break;
case FloodFilterPolicy::RULE_IN_REGION:
snprintf(compact_incoming, sizeof(compact_incoming), "r:%s",
entry.incoming_scope_name);
break;
default:
copyString(compact_incoming, "*", sizeof(compact_incoming));
break;
}
char compact_action[44];
if (entry.drop_on_match) {
copyString(compact_action, " drop", sizeof(compact_action));
} else if (entry.scope_name[0] != 0) {
snprintf(compact_action, sizeof(compact_action), " scope=%s",
entry.scope_name);
} else if (entry.target_region_name[0] != 0) {
snprintf(compact_action, sizeof(compact_action), " region=%s",
entry.target_region_name);
} else {
compact_action[0] = 0;
}
char compact_rate[12];
compact_rate[0] = 0;
if (entry.rate_limit_enabled) {
snprintf(compact_rate, sizeof(compact_rate), " q=%u",
(unsigned int)entry.rate_per_minute);
}
char compact_flags[8];
compact_flags[0] = 0;
if (entry.scope_uses_slow_timing || entry.suspend_on_temp_radio) {
snprintf(compact_flags, sizeof(compact_flags), " f=%s%s",
entry.scope_uses_slow_timing ? "s" : "",
entry.suspend_on_temp_radio ? "t" : "");
}
snprintf(reply, reply_len,
">%d %s %s c=%s p=%s i=%s%s%s pri=%u%s%s",
index + 1, compact_type, hops,
entry.channel_key_len == 0 ? "*" : entry.channel_name,
prefix, compact_incoming, compact_action, compact_rate,
(unsigned int)entry.priority,
entry.stop_on_match ? " stop" : "",
compact_flags);
}
void FloodRuleEngine::format(const char* args, char* reply) const {
const char* selector = skipSpaces(args);
if (*selector == '.') selector = skipSpaces(selector + 1);
if (*selector != 0) {
uint32_t slot = 0;
if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) {
snprintf(reply, 160, "Err - filter slot must be 1-%u",
(unsigned int)RULE_SLOTS);
return;
}
formatDetail((int)slot - 1, reply, 160);
return;
}
size_t used = (size_t)snprintf(reply, 160, ">");
int active_count = 0;
bool truncated = false;
for (int i = 0; i < RULE_SLOTS; i++) {
const Entry& entry = _entries[i];
if (!entry.active) continue;
active_count++;
char hops[12];
char item[120];
char target[48];
char priority[8];
formatHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.drop_on_match) {
copyString(target, "!drop", sizeof(target));
} else if (entry.scope_name[0] != 0) {
snprintf(target, sizeof(target), ">%s", entry.scope_name);
} else if (entry.target_region_name[0] != 0) {
snprintf(target, sizeof(target), ">r:%s",
entry.target_region_name);
} else {
target[0] = 0;
}
priority[0] = 0;
if (entry.priority != 0) {
snprintf(priority, sizeof(priority), "^%u",
(unsigned int)entry.priority);
}
snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s%s",
i + 1, payloadTypeName(entry.payload_type), hops,
entry.match_blacklisted_path ? "?blacklist" : "", target,
priority, entry.stop_on_match ? "~stop" : "",
entry.rate_limit_enabled ? "~rate" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "");
size_t item_len = strlen(item);
if (used + item_len >= 156) {
truncated = true;
break;
}
memcpy(&reply[used], item, item_len + 1);
used += item_len;
}
if (active_count == 0) copyString(reply, "> off", 160);
else if (truncated) copyString(&reply[used], " ...", 160 - used);
}
void FloodRuleEngine::set(const char* args, char* reply,
bool require_explicit_action) {
const char* cursor = skipSpaces(args);
int requested_slot = -1;
if (*cursor == '.') {
cursor++;
const char* slot_start = cursor;
while (*cursor >= '0' && *cursor <= '9') cursor++;
size_t slot_len = (size_t)(cursor - slot_start);
char slot_text[8];
if (slot_len == 0 || slot_len >= sizeof(slot_text)) {
snprintf(reply, 160, "Err - filter slot must be 1-%u",
(unsigned int)RULE_SLOTS);
return;
}
memcpy(slot_text, slot_start, slot_len);
slot_text[slot_len] = 0;
uint32_t slot = 0;
if (!parseUnsigned(slot_text, RULE_SLOTS, slot) || slot == 0) {
snprintf(reply, 160, "Err - filter slot must be 1-%u",
(unsigned int)RULE_SLOTS);
return;
}
requested_slot = (int)slot - 1;
if (*cursor != ' ') {
copyString(reply, RULE_USAGE, 160);
return;
}
}
cursor = skipSpaces(cursor);
if (strlen(cursor) >= 192) {
copyString(reply, "Err - rule parameters too long", 160);
return;
}
char params[192];
copyString(params, cursor, sizeof(params));
char* tokens[18];
int token_count = 0;
char* token = params;
while (*token != 0) {
if (token_count >= 18) {
copyString(reply, "Err - too many rule parameters", 160);
return;
}
tokens[token_count++] = token;
char* separator = strchr(token, ' ');
if (separator == NULL) break;
*separator++ = 0;
while (*separator == ' ') separator++;
token = separator;
}
if (token_count == 0) {
copyString(reply, RULE_USAGE, 160);
return;
}
uint8_t payload_type = 0;
const char* type_text = asciiStartsWith(tokens[0], "type=")
? tokens[0] + strlen("type=") : tokens[0];
if (!parsePayloadType(type_text, payload_type)) {
copyString(reply,
"Err - packet type must be name, any, 0-15, or 0x00-0x0F",
160);
return;
}
uint8_t min_hops = 0;
uint8_t max_hops = MAX_HOPS;
bool hops_set = false;
bool suspend_on_temp_radio = false;
bool path_set = false;
uint8_t path_hash_size = 0;
uint8_t path_hops = 0;
uint8_t path[PATH_PREFIX_BYTES_MAX];
memset(path, 0, sizeof(path));
uint8_t incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY;
char incoming_scope_name[NAME_LEN];
memset(incoming_scope_name, 0, sizeof(incoming_scope_name));
bool incoming_set = false;
uint8_t channel_key_len = 0;
uint8_t channel_hash = 0;
uint8_t channel_secret[PUB_KEY_SIZE];
memset(channel_secret, 0, sizeof(channel_secret));
char channel_name[NAME_LEN];
memset(channel_name, 0, sizeof(channel_name));
bool channel_set = false;
bool scope_timing_set = false;
bool scope_uses_slow_timing = false;
char scope_name[NAME_LEN];
memset(scope_name, 0, sizeof(scope_name));
char target_region_name[NAME_LEN];
memset(target_region_name, 0, sizeof(target_region_name));
bool target_set = false;
bool drop_on_match = false;
bool drop_set = false;
bool rate_limit_enabled = false;
uint16_t rate_per_minute = 0;
uint8_t priority = 0;
bool priority_set = false;
bool stop_on_match = false;
for (int i = 1; i < token_count; i++) {
if (asciiEqual(tokens[i], "suspend=tempradio")) {
if (suspend_on_temp_radio) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
suspend_on_temp_radio = true;
} else if (asciiStartsWith(tokens[i], "f=")) {
const char* flags = tokens[i] + 2;
if (*flags == 0) {
copyString(reply, "Err - compact flags are s and/or t", 160);
return;
}
while (*flags != 0) {
if (*flags == 's' || *flags == 'S') {
if (scope_timing_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
scope_timing_set = true;
scope_uses_slow_timing = true;
} else if (*flags == 't' || *flags == 'T') {
if (suspend_on_temp_radio) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
suspend_on_temp_radio = true;
} else {
copyString(reply, "Err - compact flags are s and/or t", 160);
return;
}
flags++;
}
} else if (asciiEqual(tokens[i], "path=blacklist")) {
copyString(reply,
"Err - path=blacklist is repeater-only; use prefix= on rooms",
160);
return;
} else if (asciiEqual(tokens[i], "require=region")) {
if (incoming_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
incoming_set = true;
incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED;
} else if (asciiStartsWith(tokens[i], "in=")
|| asciiStartsWith(tokens[i], "i=")) {
if (incoming_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
incoming_set = true;
bool compact = asciiStartsWith(tokens[i], "i=");
const char* value = tokens[i] + (compact ? 2 : strlen("in="));
if (asciiEqual(value, "any") || (compact && asciiEqual(value, "*"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_ANY;
} else if (asciiEqual(value, "none") || asciiEqual(value, "unscoped")
|| (compact && asciiEqual(value, "n"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_NONE;
} else if (asciiEqual(value, "scoped")
|| (compact && asciiEqual(value, "s"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPED;
} else if (asciiEqual(value, "allowed") || asciiEqual(value, "known")
|| (compact && asciiEqual(value, "a"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_ALLOWED;
} else if (asciiEqual(value, "unknown")
|| (compact && asciiEqual(value, "u"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_UNKNOWN;
} else if (asciiStartsWith(value, "scope:")
|| (compact && asciiStartsWith(value, "s:"))) {
incoming_scope_kind = FloodFilterPolicy::RULE_IN_SCOPE;
const char* name = value + (asciiStartsWith(value, "scope:")
? strlen("scope:") : 2);
if (!normalizeScopeName(name, incoming_scope_name,
sizeof(incoming_scope_name))) {
copyString(reply, "Err - bad incoming scope name", 160);
return;
}
} else if (asciiStartsWith(value, "region:")
|| (compact && asciiStartsWith(value, "r:"))) {
const char* name = value + (asciiStartsWith(value, "region:")
? strlen("region:") : 2);
RegionEntry* region = _regions == NULL ? NULL
: _regions->findByNamePrefix(name);
if (region == NULL || region->isWildcard()) {
copyString(reply, "Err - bad incoming region", 160);
return;
}
incoming_scope_kind = FloodFilterPolicy::RULE_IN_REGION;
copyString(incoming_scope_name, region->name,
sizeof(incoming_scope_name));
} else {
copyString(reply,
"Err - in=any|none|scoped|allowed|unknown|scope:name|region:name",
160);
return;
}
} else if (asciiStartsWith(tokens[i], "priority=")
|| asciiStartsWith(tokens[i], "pri=")) {
if (priority_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
const char* value = strchr(tokens[i], '=') + 1;
uint32_t parsed = 0;
if (!parseUnsigned(value, 255, parsed)) {
copyString(reply, "Err - priority must be 0-255", 160);
return;
}
priority = (uint8_t)parsed;
priority_set = true;
} else if (asciiEqual(tokens[i], "stop")
|| asciiEqual(tokens[i], "action=stop")) {
if (stop_on_match) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
stop_on_match = true;
} else if (asciiStartsWith(tokens[i], "tx=")) {
if (scope_timing_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
if (asciiEqual(tokens[i], "tx=slow")) {
scope_uses_slow_timing = true;
} else if (!asciiEqual(tokens[i], "tx=fast")) {
copyString(reply, "Err - tx timing must be fast or slow", 160);
return;
}
scope_timing_set = true;
} else if (asciiStartsWith(tokens[i], "scope=")) {
if (target_set || drop_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
if (!normalizeScopeName(tokens[i] + strlen("scope="), scope_name,
sizeof(scope_name))) {
copyString(reply,
"Err - scope must be a public name of at most 30 characters",
160);
return;
}
target_set = true;
} else if (asciiStartsWith(tokens[i], "region=")) {
if (target_set || drop_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
RegionEntry* region = _regions == NULL ? NULL
: _regions->findByNamePrefix(tokens[i] + strlen("region="));
TransportKey target_scope;
if (region == NULL || region->isWildcard()
|| (region->flags & REGION_DENY_FLOOD) != 0
|| _regions->getTransportKeysFor(*region, &target_scope, 1) <= 0
|| target_scope.isNull()) {
copyString(reply, "Err - bad target region", 160);
return;
}
copyString(target_region_name, region->name,
sizeof(target_region_name));
target_set = true;
} else if (asciiEqual(tokens[i], "drop")
|| asciiEqual(tokens[i], "action=drop")) {
if (drop_set || target_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
drop_on_match = true;
drop_set = true;
} else if (asciiStartsWith(tokens[i], "rate=")
|| asciiStartsWith(tokens[i], "q=")) {
if (rate_limit_enabled) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
bool compact = asciiStartsWith(tokens[i], "q=");
char rate_text[24];
copyString(rate_text,
tokens[i] + (compact ? 2 : strlen("rate=")),
sizeof(rate_text));
char* slash = strchr(rate_text, '/');
if ((!compact && slash == NULL)
|| (slash != NULL && !(strcmp(slash, "/min") == 0
|| strcmp(slash, "/m") == 0))) {
copyString(reply, "Err - rate format is X/min", 160);
return;
}
if (slash != NULL) *slash = 0;
uint32_t parsed = 0;
if (!parseUnsigned(rate_text, RATE_UNLIMITED - 1, parsed)) {
copyString(reply, "Err - rate must be 0-65534/min", 160);
return;
}
rate_per_minute = (uint16_t)parsed;
rate_limit_enabled = true;
} else if (asciiStartsWith(tokens[i], "channel=")
|| asciiStartsWith(tokens[i], "c=")) {
if (channel_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
channel_set = true;
const char* value = tokens[i] + (asciiStartsWith(tokens[i], "c=")
? 2 : strlen("channel="));
if (!asciiEqual(value, "*")) {
if (!parseChannel(value, channel_secret, channel_key_len,
channel_hash, channel_name,
sizeof(channel_name))) {
copyString(reply,
"Err - channel must be *, public, #channel, or a key",
160);
return;
}
}
} else if (asciiStartsWith(tokens[i], "prefix=")
|| asciiStartsWith(tokens[i], "p=")
|| asciiStartsWith(tokens[i], "path=")) {
if (path_set) {
copyString(reply, DUPLICATE_OPTION, 160);
return;
}
const char* value = strchr(tokens[i], '=') + 1;
if (!parsePathPrefix(value, path_hash_size, path_hops, path)) {
copyString(reply,
"Err - prefix is * or 1-3 comma-separated 1/2/3-byte IDs",
160);
return;
}
path_set = true;
} else if (asciiStartsWith(tokens[i], "hops=")) {
if (hops_set || !parseHopSpec(tokens[i] + strlen("hops="),
min_hops, max_hops)) {
copyString(reply,
"Err - hops must be all, N, N+, or N-M (0-63)", 160);
return;
}
hops_set = true;
} else if (!hops_set
&& parseHopSpec(tokens[i], min_hops, max_hops)) {
hops_set = true;
} else {
copyString(reply, RULE_USAGE, 160);
return;
}
}
if (scope_timing_set && !target_set) {
copyString(reply, "Err - tx timing requires scope= or region=", 160);
return;
}
if (drop_on_match && rate_limit_enabled) {
copyString(reply, "Err - drop cannot be combined with rate", 160);
return;
}
if (channel_key_len != 0 && payload_type != ANY_TYPE
&& payload_type != PAYLOAD_TYPE_GRP_TXT
&& payload_type != PAYLOAD_TYPE_GRP_DATA) {
copyString(reply,
"Err - channel matcher requires type=any|grp_txt|grp_data",
160);
return;
}
bool action_set = drop_set || target_set || rate_limit_enabled
|| stop_on_match;
if (require_explicit_action && !action_set) {
copyString(reply,
"Err - flood.rule requires drop, scope=, region=, rate=, or stop",
160);
return;
}
if (!action_set) drop_on_match = true;
Entry candidate;
memset(&candidate, 0, sizeof(candidate));
candidate.active = true;
candidate.payload_type = payload_type;
candidate.min_hops = min_hops;
candidate.max_hops = max_hops;
candidate.suspend_on_temp_radio = suspend_on_temp_radio;
candidate.scope_uses_slow_timing = scope_uses_slow_timing;
candidate.incoming_scope_kind = incoming_scope_kind;
copyString(candidate.incoming_scope_name, incoming_scope_name,
sizeof(candidate.incoming_scope_name));
candidate.channel_key_len = channel_key_len;
candidate.channel_hash = channel_hash;
memcpy(candidate.channel_secret, channel_secret,
sizeof(candidate.channel_secret));
copyString(candidate.channel_name, channel_name,
sizeof(candidate.channel_name));
candidate.path_hash_size = path_hash_size;
candidate.path_hops = path_hops;
memcpy(candidate.path, path, sizeof(candidate.path));
copyString(candidate.scope_name, scope_name, sizeof(candidate.scope_name));
copyString(candidate.target_region_name, target_region_name,
sizeof(candidate.target_region_name));
candidate.drop_on_match = drop_on_match;
candidate.rate_limit_enabled = rate_limit_enabled;
candidate.rate_per_minute = rate_per_minute;
candidate.priority = priority;
candidate.stop_on_match = stop_on_match;
int slot = requested_slot;
if (slot < 0) {
for (int i = 0; i < RULE_SLOTS; i++) {
if (_entries[i].active
&& memcmp(&_entries[i], &candidate,
offsetof(Entry, rate_window_started)) == 0) {
slot = i;
break;
}
}
}
if (slot < 0) {
for (int i = 0; i < RULE_SLOTS; i++) {
if (!_entries[i].active) {
slot = i;
break;
}
}
}
if (slot < 0) {
copyString(reply, "Err - filter table full", 160);
return;
}
Entry previous = _entries[slot];
_entries[slot] = candidate;
if (!save()) {
_entries[slot] = previous;
copyString(reply, "Err - unable to save flood filter", 160);
return;
}
char detail[160];
formatDetail(slot, detail, sizeof(detail));
const char* text = detail[0] == '>' ? skipSpaces(detail + 1) : detail;
snprintf(reply, 160, "OK - %s", text);
}
void FloodRuleEngine::remove(const char* args, char* reply) {
const char* selector = skipSpaces(args);
if (*selector == '.') selector = skipSpaces(selector + 1);
if (asciiEqual(selector, "all")) {
Entry previous[RULE_SLOTS];
memcpy(previous, _entries, sizeof(previous));
memset(_entries, 0, sizeof(_entries));
if (!save()) {
memcpy(_entries, previous, sizeof(_entries));
copyString(reply, "Err - unable to save flood filter", 160);
} else {
copyString(reply, "OK - all flood filters removed", 160);
}
return;
}
uint32_t slot = 0;
if (!parseUnsigned(selector, RULE_SLOTS, slot) || slot == 0) {
snprintf(reply, 160, "Err - use: del flood.rule.<1-%u>|all",
(unsigned int)RULE_SLOTS);
return;
}
int index = (int)slot - 1;
if (!_entries[index].active) {
copyString(reply, "Err - empty filter slot", 160);
return;
}
Entry previous = _entries[index];
memset(&_entries[index], 0, sizeof(_entries[index]));
if (!save()) {
_entries[index] = previous;
copyString(reply, "Err - unable to save flood filter", 160);
} else {
copyString(reply, "OK", 160);
}
}
bool FloodRuleEngine::handleCommand(const char* command, char* reply) {
if (command == NULL || reply == NULL) return false;
if (commandMatches(command, "get flood.filter.blacklist")
|| commandMatches(command, "set flood.filter.blacklist")
|| commandMatches(command, "del flood.filter.blacklist")) {
copyString(reply,
"Err - flood.filter.blacklist is repeater-only; use prefix=",
160);
return true;
}
if (commandMatches(command, "get flood.rule")) {
format(command + strlen("get flood.rule"), reply);
return true;
}
if (commandMatches(command, "set flood.rule")) {
set(command + strlen("set flood.rule"), reply, true);
return true;
}
if (commandMatches(command, "del flood.rule")) {
remove(command + strlen("del flood.rule"), reply);
return true;
}
if (commandMatches(command, "get flood.filter")) {
format(command + strlen("get flood.filter"), reply);
return true;
}
if (commandMatches(command, "set flood.filter")) {
set(command + strlen("set flood.filter"), reply, false);
return true;
}
if (commandMatches(command, "del flood.filter")) {
remove(command + strlen("del flood.filter"), reply);
return true;
}
return false;
}
#endif