diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 02aaf4249b..cba2d2e562 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -25,5 +25,14 @@ runs: - name: Extract Version from Git Tag shell: bash run: | - GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "GIT_TAG_VERSION=${GIT_TAG_NAME##*-}" >> $GITHUB_ENV + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + # triggered by a tag push (e.g: refs/tags/companion-v1.2.3) + GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" + VERSION_STRING="${GIT_TAG_NAME##*-}" + else + # triggered by a workflow dispatch (e.g: refs/heads/main) + # strip "refs/heads/" prefix and replace any remaining "/" with "-" to protect file paths + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + VERSION_STRING=$(echo "$BRANCH_NAME" | tr '/' '-') + fi + echo "GIT_TAG_VERSION=${VERSION_STRING}" >> $GITHUB_ENV diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-firmwares.yml index 771fa6d5bc..8fd796f74b 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-firmwares.yml @@ -10,33 +10,8 @@ on: - 'companion-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-companion-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: companion-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Companion Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-companion-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'companion' + release_title_prefix: 'Companion Firmware' diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml index 3185d4b21c..46b9076cc0 100644 --- a/.github/workflows/build-repeater-firmwares.yml +++ b/.github/workflows/build-repeater-firmwares.yml @@ -10,33 +10,8 @@ on: - 'repeater-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-repeater-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: repeater-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Repeater Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-repeater-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'repeater' + release_title_prefix: 'Repeater Firmware' diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-room-server-firmwares.yml index 127095a8df..c8bd19f7f2 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-room-server-firmwares.yml @@ -10,33 +10,8 @@ on: - 'room-server-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-room-server-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: room-server-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Room Server Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-room-server-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'room-server' + release_title_prefix: 'Room Server Firmware' diff --git a/.github/workflows/firmware-builder.yml b/.github/workflows/firmware-builder.yml new file mode 100644 index 0000000000..adc9e42488 --- /dev/null +++ b/.github/workflows/firmware-builder.yml @@ -0,0 +1,90 @@ +name: Firmware Builder + +on: + workflow_call: + inputs: + firmware_type: + required: true + type: string + release_title_prefix: + required: true + type: string + +jobs: + + generate-build-matrix: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.get-build-targets.outputs.targets }} + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Get Build Targets + id: get-build-targets + run: | + # get list of firmwares to build + TARGET_LIST=$(/usr/bin/env bash build.sh get-${{ inputs.firmware_type }}-firmwares-to-build) + + # convert targets separated by new line into a json array string + JSON_ARRAY=$(echo "$TARGET_LIST" | jq -R -s -c 'split("\n") | map(select(length > 0))') + + # use json array as targets result + echo "targets=$JSON_ARRAY" >> $GITHUB_OUTPUT + + build: + needs: generate-build-matrix + runs-on: ubuntu-latest + continue-on-error: true # don't fail entire build if one board fails to build + strategy: + matrix: + target: ${{ fromJson(needs.generate-build-matrix.outputs.targets) }} + fail-fast: false # don't cancel other builds if one board fails to build + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Firmware + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-firmware ${{ matrix.target }} + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v7 + with: + name: "${{ matrix.target }}" + path: out + + create-release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') # only create release for tagged builds + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Download All Artifacts + uses: actions/download-artifact@v8 + with: + merge-multiple: true + path: out + + - name: Create Release + uses: softprops/action-gh-release@v3 + with: + name: "${{ inputs.release_title_prefix }} ${{ env.GIT_TAG_VERSION }}" + body: "" + draft: true + files: out/* diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 0000000000..ec16658756 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,32 @@ +name: 'Run Stale Bot' +on: + schedule: + - cron: '30 1 * * *' # daily at 1:30am + workflow_dispatch: {} + +permissions: + actions: write + issues: write + pull-requests: write + +jobs: + close-issues: + # only run on main repo, not forks + if: github.repository == 'meshcore-dev/MeshCore' + runs-on: ubuntu-latest + steps: + - name: Close Stale Issues + uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # auto close issues + days-before-issue-stale: 60 + days-before-issue-close: 7 + exempt-issue-labels: "keep-open" + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 60 days with no activity. Remove the stale label or add a comment if this issue is still relevant, otherwise this issue will automatically close in 7 days." + close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." + # don't auto close prs + days-before-pr-stale: -1 + days-before-pr-close: -1 + \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..a4b2207d55 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to the latest release only. We do not backport +fixes to older versions. + +| Version | Supported | +|---------|-----------| +| 1.15+ | ✅ | +| <1.15 | ❌ | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Use GitHub's private vulnerability reporting instead: +1. Go to the **Security** tab of this repository +2. Click **Report a vulnerability** +3. Fill in the details and submit + +### What to include + +A useful report tells us: +- Which component or file is affected +- What an attacker can do (impact) and under what conditions +- A minimal reproduction case or proof-of-concept if you have one +- Whether you believe it is remotely exploitable + +You do not need a working exploit to report. An incomplete report is better +than no report. + +## What to expect + +This is a volunteer-maintained open-source project. We will do our best to +respond in a reasonable timeframe, but cannot commit to specific deadlines. + +We ask that you give us a fair opportunity to investigate and address the +issue before any public disclosure. If you have not heard back after +**90 days**, feel free to follow up or proceed with disclosure at your +discretion. + +## Scope + +In scope: +- Remote code execution, memory corruption, or denial-of-service via crafted + radio packets +- Authentication or encryption bypasses +- Vulnerabilities in the packet routing or path handling logic + +Out of scope: +- Physical access attacks (e.g., JTAG, UART extraction of keys) +- Regulatory compliance (duty cycle, frequency restrictions) +- Jamming or other physical-layer radio interference +- Issues in third-party libraries (RadioLib, Crypto, etc.) — report those + upstream +- "Best practice" suggestions without a demonstrated attack path diff --git a/build-repeaters-filter.sh b/build-repeaters-filter.sh new file mode 100644 index 0000000000..4f2ebfbff5 --- /dev/null +++ b/build-repeaters-filter.sh @@ -0,0 +1,10 @@ +# sh ./build-repeaters-filter.sh +export FIRMWARE_VERSION="PowerSaving16-Filter" + +############# Repeaters ############# +# Commonly-used boards +## ESP32 - 17 boards +sh build.sh build-firmware \ +heltec_v4_repeater \ +Heltec_t096_repeater \ +RAK_4631_repeater diff --git a/build.sh b/build.sh index 006eae9698..6c7494c289 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# exit when any command fails +set -e + global_usage() { cat - <` + +**Parameters:** +- `value`: Maximum flood hop count (0-64) for an advert packet + +**Default:** `8` --- diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 7cca7bc9a2..8c6b84b973 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -440,6 +440,8 @@ Byte 0: 0x14 - Uses a secret key derived from the channel name - It is the first 16 bytes of `sha256("#test")` - For example hashtag channel `#test` has the key: `9cd8fcf22a47333b591d96a2b848b73f` + - Traffic is encrypted on air, but anyone who knows or guesses the channel + name can derive the key. Hashtag channels should not be treated as private. - Used as a topic based public group chat, separate from the default public channel 3. **Private Channels** - Uses a randomly generated 16-byte secret key diff --git a/docs/payloads.md b/docs/payloads.md index 21cb94696c..9d2a8a113b 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -236,6 +236,9 @@ txt_type The plaintext contained in the ciphertext matches the format described in [plain text message](#plain-text-message). Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be `0x00` because it is a "plain text message". The message will be of the form `: ` (eg., `user123: I'm on my way`). +The sender name is unverified message text. Group messages contain no sender +signature, so any channel-key holder can choose any sender name. + # Group datagram | Field | Size (bytes) | Description | diff --git a/docs/qr_codes.md b/docs/qr_codes.md index 364efa8acc..3516a76a49 100644 --- a/docs/qr_codes.md +++ b/docs/qr_codes.md @@ -12,8 +12,10 @@ meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72 **Parameters**: -- `name`: Channel name (URL-encoded if needed) +- `name`: Channel name (URL-encoded) - `secret`: 16-byte secret represented as 32 hex characters +- `region_scope`: Region Scope (optional, URL-encoded if provided) + - Supported by MeshCore App v1.47.0+ ## Add Contact diff --git a/examples/filter test suite/ChannelFilter.cpp b/examples/filter test suite/ChannelFilter.cpp new file mode 100644 index 0000000000..3069cb20db --- /dev/null +++ b/examples/filter test suite/ChannelFilter.cpp @@ -0,0 +1,574 @@ +#include "mock_mesh.h" +#include "ChannelFilter.h" +#include +#include + +// --------------------------------------------------------------------------- +// Persistence layout (binary blob, fixed size): +// [uint8_t magic = FILTER_FILE_MAGIC] +// [uint8_t version = FILTER_FILE_VERSION] +// [uint8_t mode] +// [FilterRule * MAX_FILTER_RULES] +// --------------------------------------------------------------------------- + +ChannelFilter::ChannelFilter() { + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +void ChannelFilter::load(FILESYSTEM &fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "r"); +#else + File f = fs.open(FILTER_RULES_FILE); +#endif + if (!f) return; + + // Validate magic byte — old or corrupt file starts fresh + uint8_t magic; + if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { + f.close(); + return; + } + + // Validate version — discard files older than current version + uint8_t version; + if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { + f.close(); + return; + } + + // Read mode byte + uint8_t mode_byte; + if (f.read(&mode_byte, 1) != 1) { + f.close(); + return; + } + // Validate mode byte — default to ALLOW if file is corrupt + _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + ? (FilterMode)mode_byte + : FilterMode::ALLOW; + + // Check read length — if truncated, zero remaining slots (in_use=false = harmless) + size_t bytes_read = f.read((uint8_t *)_rules, sizeof(_rules)); + if (bytes_read < sizeof(_rules)) { + memset((uint8_t *)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); + } + f.close(); +} + +void ChannelFilter::save(FILESYSTEM &fs) const { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "w"); +#else + if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, "w"); +#endif + if (!f) return; + + uint8_t header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; + f.write(header, sizeof(header)); + f.write((const uint8_t *)_rules, sizeof(_rules)); + f.close(); +} + +// --------------------------------------------------------------------------- +// Evaluation helpers +// --------------------------------------------------------------------------- + +static bool applyOp(FilterOp op, int16_t pkt_val, int16_t rule_val) { + switch (op) { + case FilterOp::EQ: + return pkt_val == rule_val; + case FilterOp::NEQ: + return pkt_val != rule_val; + case FilterOp::GT: + return pkt_val > rule_val; + case FilterOp::LT: + return pkt_val < rule_val; + default: + return false; + } +} + +bool ChannelFilter::_ruleMatches(const FilterRule &rule, const mesh::Packet *pkt, int16_t rssi) const { + // PATH field has its own OR-list logic — handle separately + if (rule.field == FilterField::PATH) { + uint8_t hash_size = pkt->getPathHashSize(); + uint8_t hash_count = pkt->getPathHashCount(); + + if (hash_count == 0) return false; + if (hash_size != rule.path_hash_len) return false; + + uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; + if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; + + const uint8_t *last_hop = pkt->path + last_hop_offset; + bool found = false; + for (uint8_t i = 0; i < rule.path_hash_count; i++) { + if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { + found = true; + break; + } + } + + bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; + if (!primary_match) return false; + + // AND condition (PATH as primary can still have a scalar AND) + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // CHANNEL field has OR-list logic + if (rule.field == FilterField::CHANNEL) { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; // GRP_TXT / GRP_DATA only + if (pkt->payload_len < 1) return false; + + uint8_t ch = pkt->payload[0]; + bool found = false; + for (uint8_t i = 0; i < rule.channel_hash_count; i++) { + if (rule.channel_hashes[i] == ch) { + found = true; + break; + } + } + + bool primary_match; + if (rule.op == FilterOp::EQ) primary_match = found; + else if (rule.op == FilterOp::NEQ) primary_match = !found; + else return false; // GT/LT not meaningful for channel OR-list + + if (!primary_match) return false; + + // AND condition for channel primary + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + + // AND condition if present + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + } + + return true; +} + +// Evaluate a single scalar condition against a packet. +// PATH field is not handled here — it has its own block in the switch above. +bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet *pkt, int16_t rssi) const { + switch (field) { + case FilterField::ROUTE: + return applyOp(op, (int16_t)pkt->getRouteType(), val); + + case FilterField::TYPE: + return applyOp(op, (int16_t)pkt->getPayloadType(), val); + + case FilterField::HOPS: + return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + + case FilterField::PATHSIZE: + return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + + case FilterField::CHANNEL: { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; + if (pkt->payload_len < 1) return false; + // OR-match against channel_hashes list — find the rule via pointer arithmetic + // Note: val is unused for CHANNEL — match logic uses the rule struct directly. + // This case should not be reached via _evalScalar for channel primary field. + // It remains here as a safety fallback using val as single-value compare. + return applyOp(op, (int16_t)pkt->payload[0], val); + } + + case FilterField::SNR: + return applyOp(op, (int16_t)pkt->_snr, val); + + case FilterField::RSSI: + return applyOp(op, rssi, val); + + default: + return false; + } +} + +bool ChannelFilter::evaluate(const mesh::Packet *pkt, int16_t rssi) const { + if (!pkt) return false; // null guard — pass unknown packets rather than crash + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + const FilterRule &rule = _rules[i]; + if (!rule.in_use || !rule.enabled) continue; + + if (_ruleMatches(rule, pkt, rssi)) { + return rule.action == FilterAction::DROP; + } + } + // No rule matched — apply default policy + return _mode == FilterMode::DROP; +} + +// --------------------------------------------------------------------------- +// Slot helpers +// --------------------------------------------------------------------------- + +int ChannelFilter::_firstFreeSlot() const { + for (int i = 0; i < MAX_FILTER_RULES; i++) { + if (!_rules[i].in_use) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// List formatting +// --------------------------------------------------------------------------- + +// Return a short token string for a FilterField value +static const char *fieldStr(FilterField f) { + switch (f) { + case FilterField::ROUTE: + return "route"; + case FilterField::TYPE: + return "payload"; + case FilterField::HOPS: + return "hops"; + case FilterField::PATHSIZE: + return "pathsize"; + case FilterField::PATH: + return "path"; + case FilterField::CHANNEL: + return "channel"; + case FilterField::SNR: + return "snr"; + case FilterField::RSSI: + return "rssi"; + default: + return "?"; + } +} + +static const char *opStr(FilterOp op) { + switch (op) { + case FilterOp::EQ: + return "eq"; + case FilterOp::NEQ: + return "neq"; + case FilterOp::GT: + return "gt"; + case FilterOp::LT: + return "lt"; + default: + return "?"; + } +} + +// Translate ROUTE_TYPE_* numeric value to token string +static const char *routeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "tflood"; + case 0x01: + return "flood"; + case 0x02: + return "direct"; + case 0x03: + return "tdirect"; + default: + return "?"; + } +} + +// Translate PAYLOAD_TYPE_* numeric value to token string +static const char *payloadTypeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "req"; + case 0x01: + return "resp"; + case 0x02: + return "txt"; + case 0x03: + return "ack"; + case 0x04: + return "advert"; + case 0x05: + return "grptxt"; + case 0x06: + return "grpdata"; + case 0x07: + return "anonreq"; + case 0x08: + return "path"; + case 0x09: + return "trace"; + case 0x0A: + return "multi"; + case 0x0B: + return "ctrl"; + case 0x0F: + return "raw"; + default: + return "?"; + } +} + +static void formatRuleValue(const FilterRule &rule, char *out, int outlen) { + if (outlen <= 0) return; + if (rule.field == FilterField::PATH) { + int pos = 0; + for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { + pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); + } + } + out[pos] = '\0'; + } else if (rule.field == FilterField::ROUTE) { + snprintf(out, outlen, "%s", routeValueStr(rule.value)); + } else if (rule.field == FilterField::TYPE) { + snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); + } else if (rule.field == FilterField::CHANNEL) { + int pos = 0; + for (uint8_t i = 0; i < rule.channel_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + pos += snprintf(out + pos, outlen - pos, "0x%02X", rule.channel_hashes[i]); + } + out[pos] = '\0'; + } else if (rule.field == FilterField::SNR) { + // Convert stored quarter-dB back to whole dB for display + snprintf(out, outlen, "%d", (int)(rule.value / 4)); + } else { + snprintf(out, outlen, "%d", (int)rule.value); + } +} + +// Maximum reply length — stay safely below the 138-char packet limit +#define FILTER_REPLY_BUDGET 128 +// Reserved for header and hint line +#define FILTER_REPLY_HEADER_MAX 32 +#define FILTER_REPLY_HINT_LEN 18 // "-> filter list N\0" + +// Format a single rule line into buf (null-terminated). Returns number of chars written. +static int formatRuleLine(const FilterRule &rule, uint8_t idx, char *buf, int buflen) { + char val_buf[32]; + formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); + + char and_buf[48] = ""; + if (rule.and_field != FILTER_FIELD_NONE) { + char and_val_buf[32]; + FilterField af = (FilterField)rule.and_field; + if (af == FilterField::ROUTE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); + } else if (af == FilterField::TYPE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); + } else if (af == FilterField::CHANNEL) { + snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); + } else if (af == FilterField::SNR) { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); + } else { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); + } + snprintf(and_buf, sizeof(and_buf), " and %s %s %s", + fieldStr(af), opStr(rule.and_op), and_val_buf); + } + + return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", + idx, + rule.enabled ? "" : "*", + rule.action == FilterAction::DROP ? "drop" : "allow", + fieldStr(rule.field), + opStr(rule.op), + val_buf, + and_buf + ); +} + +void ChannelFilter::_listRules(char *reply, uint8_t page, bool is_remote) const { + // Count in-use rules and collect their indexes + uint8_t indexes[MAX_FILTER_RULES]; + uint8_t total = 0; + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + if (_rules[i].in_use) indexes[total++] = i; + } + + const char *mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + + if (!is_remote) { + Serial.printf("mode:%s rules:%d/%d\n", mode_str, total, MAX_FILTER_RULES); + if (total > 0) { + for (uint8_t i = 0; i < total; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + Serial.printf("%s", line_buf); + } + } + reply[0] = '\0'; + return; + } else { + // Pre-scan: determine page boundaries dynamically based on actual line lengths. + // Each page gets as many rules as fit within FILTER_REPLY_BUDGET minus header and hint. + uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page + uint8_t num_pages = 0; + page_start[0] = 0; + + { + uint8_t i = 0; + while (i < total) { + // Available budget for rule lines on this page + int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; + uint8_t page_end = i; + + while (page_end < total) { + char line_buf[80]; + int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], + line_buf, sizeof(line_buf)); + if (budget - line_len < 0) break; // doesn't fit + budget -= line_len; + page_end++; + } + + // Safety: always advance at least one rule to avoid infinite loop + if (page_end == i) page_end = i + 1; + + num_pages++; + i = page_end; + page_start[num_pages] = i; + } + } + + if (total == 0) num_pages = 1; + + if (page >= num_pages) { + snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); + return; + } + + // Write header + int pos; + if (num_pages > 1) { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", + mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); + } else { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", + mode_str, total, MAX_FILTER_RULES); + } + + if (total == 0) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); + return; + } + + // Write rule lines for this page + uint8_t start = page_start[page]; + uint8_t end = page_start[page + 1]; + + for (uint8_t i = start; i < end; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); + } + + // Hint if more pages follow + if (page + 1 < num_pages) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); + } + } +} + +// --------------------------------------------------------------------------- +// CLI dispatch +// --------------------------------------------------------------------------- + +void ChannelFilter::handleCommand(const char *args, char *reply, FILESYSTEM &fs, ClientInfo *sender) { + bool is_remote = (sender != NULL); // Check if commands are from Serial (ClientInfo = NULL). + FilterParseResult res = parseFilterCommand(args); + + if (res.error != FilterParseError::OK) { + snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); + return; + } + + switch (res.command) { + case FilterCommand::ADD: { + int slot = _firstFreeSlot(); + if (slot < 0) { + snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); + return; + } + _rules[slot] = res.rule; + save(fs); + snprintf(reply, 80, "OK - rule %d added", slot); + break; + } + + case FilterCommand::DEL: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + memset(&_rules[id], 0, sizeof(FilterRule)); + save(fs); + snprintf(reply, 80, "OK - rule %d deleted", id); + break; + } + + case FilterCommand::DISABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = false; + save(fs); + snprintf(reply, 80, "OK - rule %d disabled", id); + break; + } + + case FilterCommand::ENABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = true; + save(fs); + snprintf(reply, 80, "OK - rule %d enabled", id); + break; + } + + case FilterCommand::LIST: + _listRules(reply, res.rule_id, is_remote); + break; + + case FilterCommand::CLEAR: + memset(_rules, 0, sizeof(_rules)); + save(fs); + snprintf(reply, 80, "OK - all rules cleared"); + break; + + case FilterCommand::MODE: + _mode = res.mode; + save(fs); + snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); + break; + } +} \ No newline at end of file diff --git a/examples/filter test suite/ChannelFilter.h b/examples/filter test suite/ChannelFilter.h new file mode 100644 index 0000000000..73182867cc --- /dev/null +++ b/examples/filter test suite/ChannelFilter.h @@ -0,0 +1,63 @@ +#pragma once + +// NOTE: This header relies on FILESYSTEM being defined before inclusion. +// MyMesh.h includes the platform-specific filesystem headers before including +// this file, so FILESYSTEM is always defined in that context. + +#include "FilterRule.h" +#include "FilterParser.h" + + +// Persistence file path +#define FILTER_RULES_FILE "/filter_rules.bin" + +// File format: [magic] [version] [mode] [FilterRule * MAX_FILTER_RULES] +// magic : 0xFC — identifies this as a valid filter rules file +// version: 2 — current struct version; files with version < 2 are discarded +#define FILTER_FILE_MAGIC 0xFC +#define FILTER_FILE_VERSION 3 + +// --------------------------------------------------------------------------- +// ChannelFilter +// --------------------------------------------------------------------------- + +class ChannelFilter { +public: + ChannelFilter(); + + // --- Lifecycle ---------------------------------------------------------- + + // Load rules and mode from filesystem. Call once at startup. + void load(FILESYSTEM& fs); + + // Save rules and mode to filesystem. + void save(FILESYSTEM& fs) const; + + // --- Evaluation --------------------------------------------------------- + + // Evaluate all active rules against a received packet. + // 'rssi' is passed separately as it lives in the radio driver, not in Packet. + // Returns true if the packet should be DROPPED, false if it should pass. + bool evaluate(const mesh::Packet* pkt, int16_t rssi) const; + + // --- CLI dispatch ------------------------------------------------------- + + // Handle a "filter ..." command string (everything after "filter "). + // Writes a human-readable result into 'reply' (assumed >= 80 bytes). + // sender == nullptr means local Serial CLI; sender != nullptr means remote CLI (paginates output). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs, ClientInfo* sender = nullptr); + +private: + FilterRule _rules[MAX_FILTER_RULES]; + FilterMode _mode; // default policy when no rule matches + + // --- Rule helpers ------------------------------------------------------- + + int _firstFreeSlot() const; + bool _ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const; + bool _evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const; + + // --- list command ------------------------------------------------------- + void _listRules(char* reply, uint8_t page, bool is_remote) const; +}; diff --git a/examples/filter test suite/FilterParser.cpp b/examples/filter test suite/FilterParser.cpp new file mode 100644 index 0000000000..c4d58b1c82 --- /dev/null +++ b/examples/filter test suite/FilterParser.cpp @@ -0,0 +1,449 @@ +#include "FilterParser.h" +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal tokenizer +// --------------------------------------------------------------------------- + +// Maximum token length (no single token should exceed this) +#define MAX_TOKEN_LEN 16 + +struct Tokenizer { + const char* pos; // current position in input string +}; + +// Copy the next whitespace-delimited token into 'out' (null-terminated). +// Returns false if no more tokens are available. +static bool nextToken(Tokenizer& tz, char out[MAX_TOKEN_LEN + 1]) { + // Skip leading whitespace + while (*tz.pos == ' ' || *tz.pos == '\t') tz.pos++; + + if (*tz.pos == '\0') return false; + + int i = 0; + while (*tz.pos != '\0' && *tz.pos != ' ' && *tz.pos != '\t') { + if (i < MAX_TOKEN_LEN) { + out[i++] = (char)tolower((unsigned char)*tz.pos); + } + tz.pos++; + } + out[i] = '\0'; + return true; +} + +// Peek at next token without advancing position. +static bool peekToken(Tokenizer tz, char out[MAX_TOKEN_LEN + 1]) { + return nextToken(tz, out); // tz passed by value — copy is intentional +} + +// --------------------------------------------------------------------------- +// Token → enum helpers +// --------------------------------------------------------------------------- + +static bool parseAction(const char* tok, FilterAction& out) { + if (strcmp(tok, "drop") == 0) { out = FilterAction::DROP; return true; } + if (strcmp(tok, "allow") == 0) { out = FilterAction::ALLOW; return true; } + return false; +} + +static bool parseField(const char* tok, FilterField& out) { + if (strcmp(tok, "route") == 0) { out = FilterField::ROUTE; return true; } + if (strcmp(tok, "payload") == 0) { out = FilterField::TYPE; return true; } + if (strcmp(tok, "hops") == 0) { out = FilterField::HOPS; return true; } + if (strcmp(tok, "pathsize") == 0) { out = FilterField::PATHSIZE; return true; } + if (strcmp(tok, "path") == 0) { out = FilterField::PATH; return true; } + if (strcmp(tok, "channel") == 0) { out = FilterField::CHANNEL; return true; } + if (strcmp(tok, "snr") == 0) { out = FilterField::SNR; return true; } + if (strcmp(tok, "rssi") == 0) { out = FilterField::RSSI; return true; } + return false; +} + +static bool parseOp(const char* tok, FilterOp& out) { + if (strcmp(tok, "eq") == 0) { out = FilterOp::EQ; return true; } + if (strcmp(tok, "neq") == 0) { out = FilterOp::NEQ; return true; } + if (strcmp(tok, "gt") == 0) { out = FilterOp::GT; return true; } + if (strcmp(tok, "lt") == 0) { out = FilterOp::LT; return true; } + return false; +} + +static bool parseMode(const char* tok, FilterMode& out) { + if (strcmp(tok, "allow") == 0) { out = FilterMode::ALLOW; return true; } + if (strcmp(tok, "drop") == 0) { out = FilterMode::DROP; return true; } + return false; +} + +// --------------------------------------------------------------------------- +// Value parsers per field +// --------------------------------------------------------------------------- + +// Parse ROUTE value token → uint8_t ROUTE_TYPE_* equivalent +static bool parseRouteValue(const char* tok, int16_t& out) { + if (strcmp(tok, "tflood") == 0) { out = 0x00; return true; } // ROUTE_TYPE_TRANSPORT_FLOOD + if (strcmp(tok, "flood") == 0) { out = 0x01; return true; } // ROUTE_TYPE_FLOOD + if (strcmp(tok, "direct") == 0) { out = 0x02; return true; } // ROUTE_TYPE_DIRECT + if (strcmp(tok, "tdirect") == 0) { out = 0x03; return true; } // ROUTE_TYPE_TRANSPORT_DIRECT + return false; +} + +// Parse PAYLOAD_TYPE value token → uint8_t PAYLOAD_TYPE_* equivalent +static bool parseTypeValue(const char* tok, int16_t& out) { + if (strcmp(tok, "req") == 0) { out = 0x00; return true; } + if (strcmp(tok, "resp") == 0) { out = 0x01; return true; } + if (strcmp(tok, "txt") == 0) { out = 0x02; return true; } + if (strcmp(tok, "ack") == 0) { out = 0x03; return true; } + if (strcmp(tok, "advert") == 0) { out = 0x04; return true; } + if (strcmp(tok, "grptxt") == 0) { out = 0x05; return true; } + if (strcmp(tok, "grpdata") == 0) { out = 0x06; return true; } + if (strcmp(tok, "anonreq") == 0) { out = 0x07; return true; } + if (strcmp(tok, "path") == 0) { out = 0x08; return true; } + if (strcmp(tok, "trace") == 0) { out = 0x09; return true; } + if (strcmp(tok, "multi") == 0) { out = 0x0A; return true; } + if (strcmp(tok, "ctrl") == 0) { out = 0x0B; return true; } + if (strcmp(tok, "raw") == 0) { out = 0x0F; return true; } + // Also accept raw numeric values (decimal or hex) + char* end; + long v = strtol(tok, &end, 0); + if (*end == '\0' && v >= 0 && v <= 0x0F) { out = (int16_t)v; return true; } + return false; +} + +// Parse a hex string (with or without 0x prefix) into up to MAX_PATH_HASH_SIZE bytes. +// Returns number of bytes written, or 0 on failure. +static uint8_t parseHexBytes(const char* tok, uint8_t* out) { + // Skip optional 0x / 0X prefix — check both chars exist first + if (tok[0] == '0' && tok[1] != '\0' && (tok[1] == 'x' || tok[1] == 'X')) tok += 2; + + size_t hexlen = strlen(tok); + if (hexlen == 0 || hexlen > (MAX_PATH_HASH_SIZE * 2) || (hexlen & 1) != 0) return 0; + + for (size_t i = 0; i < hexlen; i += 2) { + char hi = tok[i]; + char lo = tok[i + 1]; + + auto hexdig = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + + int h = hexdig(hi); + int l = hexdig(lo); + if (h < 0 || l < 0) return 0; + + out[i / 2] = (uint8_t)((h << 4) | l); + } + return (uint8_t)(hexlen / 2); +} + +// Parse a scalar value token for a given field into out. +// Returns false if the token is not valid for the field. +static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { + switch (field) { + case FilterField::ROUTE: + return parseRouteValue(tok, out); + case FilterField::TYPE: + return parseTypeValue(tok, out); + case FilterField::HOPS: + case FilterField::PATHSIZE: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::CHANNEL: + // Channel OR-list is handled separately in parseAddCommand. + // parseScalarValue is only called for AND conditions — channel not allowed there. + return false; + case FilterField::SNR: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -128 || v > 127) return false; + out = (int16_t)(v * 4); // store as quarter-dB + return true; + } + case FilterField::RSSI: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -32768 || v > 32767) return false; + out = (int16_t)v; + return true; + } + default: + return false; + } +} + +static FilterParseResult parseAddCommand(Tokenizer& tz) { + FilterParseResult result; + result.error = FilterParseError::OK; + result.command = FilterCommand::ADD; + memset(&result.rule, 0, sizeof(FilterRule)); + result.rule.enabled = false; // rules are added disabled — use 'filter enable ' to activate + result.rule.in_use = true; + result.rule.and_field = FILTER_FIELD_NONE; // no AND condition by default + + char tok[MAX_TOKEN_LEN + 1]; + + // --- action --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseAction(tok, result.rule.action)) { result.error = FilterParseError::UNKNOWN_ACTION; return result; } + + // --- field --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseField(tok, result.rule.field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // --- operator --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, result.rule.op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // --- value (field-specific) --- + if (result.rule.field == FilterField::PATH) { + // PATH: one or more hex hash tokens (OR-list), stops at "and" or end of input + uint8_t count = 0; + uint8_t hashlen = 0; + + while (peekToken(tz, tok)) { + if (strcmp(tok, "and") == 0) break; + nextToken(tz, tok); + + if (count >= MAX_PATH_HASHES_PER_RULE) { + result.error = FilterParseError::TOO_MANY_HASHES; + return result; + } + + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 0) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + + if (hashlen == 0) { + hashlen = len; + } else if (len != hashlen) { + result.error = FilterParseError::HASH_SIZE_MISMATCH; + return result; + } + + memcpy(result.rule.path_hashes[count], bytes, len); + count++; + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.path_hash_len = hashlen; + result.rule.path_hash_count = count; + + } else if (result.rule.field == FilterField::CHANNEL) { + // CHANNEL: one or more space-separated hex byte tokens (OR-list) + // AND condition not supported when channel is primary field + uint8_t count = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); + + if (count >= MAX_CHANNEL_HASHES) { + result.error = FilterParseError::TOO_MANY_CHANNELS; + return result; + } + + // Accept 0xNN or NN (exactly 1 byte) + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { + result.rule.channel_hashes[count++] = bytes[0]; + } else { + // Try plain decimal + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + result.rule.channel_hashes[count++] = (uint8_t)v; + } + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.channel_hash_count = count; + // Channel OR-list parsed — fall through to AND condition parsing below + + } else { + // Scalar field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, result.rule.field, result.rule.value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + } + + // --- optional AND condition --- + if (peekToken(tz, tok) && strcmp(tok, "and") == 0) { + nextToken(tz, tok); // consume "and" + + // AND field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + FilterField and_field; + if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_FIELD; + return result; + } + + // PATH not supported as AND condition + if (and_field == FilterField::PATH) { + result.error = FilterParseError::AND_PATH_NOT_ALLOWED; + return result; + } + + // CHANNEL not supported as AND condition + if (and_field == FilterField::CHANNEL) { + result.error = FilterParseError::AND_CHANNEL_NOT_ALLOWED; + return result; + } + + // AND operator + FilterOp and_op; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, and_op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // AND value + int16_t and_value = 0; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, and_field, and_value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + + result.rule.and_field = (uint8_t)and_field; + result.rule.and_op = and_op; + result.rule.and_value = and_value; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +FilterParseResult parseFilterCommand(const char* input) { + FilterParseResult result; + memset(&result, 0, sizeof(result)); // zero all fields including rule — safe default for all error paths + + Tokenizer tz = { input }; + char tok[MAX_TOKEN_LEN + 1]; + + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + // --- route to sub-command --- + if (strcmp(tok, "add") == 0) { + return parseAddCommand(tz); + } + + if (strcmp(tok, "list") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::LIST; + result.rule_id = 0; // default page 0 + // Optional page number: "filter list 1" + if (peekToken(tz, tok)) { + nextToken(tz, tok); + char* end; + long page = strtol(tok, &end, 10); + if (*end != '\0' || page < 0) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.rule_id = (uint8_t)page; + } + return result; + } + + if (strcmp(tok, "clear") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::CLEAR; + return result; + } + + if (strcmp(tok, "del") == 0 || strcmp(tok, "disable") == 0 || strcmp(tok, "enable") == 0) { + FilterCommand cmd = (strcmp(tok, "del") == 0) ? FilterCommand::DEL : + (strcmp(tok, "disable") == 0) ? FilterCommand::DISABLE : + FilterCommand::ENABLE; + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + char* end; + long id = strtol(tok, &end, 10); + if (*end != '\0' || id < 0 || id >= MAX_FILTER_RULES) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.error = FilterParseError::OK; + result.command = cmd; + result.rule_id = (uint8_t)id; + return result; + } + + if (strcmp(tok, "mode") == 0) { + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + if (!parseMode(tok, result.mode)) { + result.error = FilterParseError::UNKNOWN_MODE; + return result; + } + result.error = FilterParseError::OK; + result.command = FilterCommand::MODE; + return result; + } + + result.error = FilterParseError::UNKNOWN_COMMAND; + return result; +} + +// --------------------------------------------------------------------------- +// Error string helper +// --------------------------------------------------------------------------- + +const char* filterParseErrorStr(FilterParseError err) { + switch (err) { + case FilterParseError::OK: return "OK"; + case FilterParseError::UNKNOWN_COMMAND: return "Err - unknown command"; + case FilterParseError::UNKNOWN_ACTION: return "Err - unknown action (use: drop, allow)"; + case FilterParseError::UNKNOWN_FIELD: return "Err - unknown field (use: route, payload, hops, pathsize, path, channel, snr, rssi)"; + case FilterParseError::UNKNOWN_OP: return "Err - unknown operator (use: eq, neq, gt, lt)"; + case FilterParseError::UNKNOWN_VALUE: return "Err - unknown or out-of-range value"; + case FilterParseError::MISSING_TOKEN: return "Err - missing token"; + case FilterParseError::INVALID_RULE_ID: return "Err - invalid rule id"; + case FilterParseError::INVALID_HEX: return "Err - invalid hex value"; + case FilterParseError::TOO_MANY_HASHES: return "Err - too many path hashes (max 4)"; + case FilterParseError::HASH_SIZE_MISMATCH:return "Err - mixed hash sizes in path rule"; + case FilterParseError::UNKNOWN_MODE: return "Err - unknown mode (use: allow, drop)"; + case FilterParseError::AND_PATH_NOT_ALLOWED: return "Err - path field not supported as AND condition"; + case FilterParseError::AND_DUPLICATE_FIELD: return "Err - AND condition cannot use same field as primary"; + case FilterParseError::AND_CHANNEL_NOT_ALLOWED: return "Err - channel field not supported as AND condition"; + case FilterParseError::TOO_MANY_CHANNELS: return "Err - too many channel hashes (max 4)"; + default: return "Err - unknown error"; + } +} diff --git a/examples/filter test suite/FilterParser.h b/examples/filter test suite/FilterParser.h new file mode 100644 index 0000000000..5904098c86 --- /dev/null +++ b/examples/filter test suite/FilterParser.h @@ -0,0 +1,64 @@ +#pragma once + +#include "FilterRule.h" + +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +enum class FilterParseError : uint8_t { + OK = 0, + UNKNOWN_COMMAND, // unrecognised sub-command after "filter" + UNKNOWN_ACTION, // unrecognised action token (expected drop/allow) + UNKNOWN_FIELD, // unrecognised field token + UNKNOWN_OP, // unrecognised operator token + UNKNOWN_VALUE, // unrecognised or out-of-range value token + MISSING_TOKEN, // expected another token but input ended + INVALID_RULE_ID, // rule id out of range or not a number + INVALID_HEX, // malformed hex string + TOO_MANY_HASHES, // more path hashes than MAX_PATH_HASHES_PER_RULE + HASH_SIZE_MISMATCH, // mixed hash sizes in a single path rule + UNKNOWN_MODE, // unrecognised mode token (expected allow/drop) + AND_PATH_NOT_ALLOWED, // path field not supported as AND condition + AND_DUPLICATE_FIELD, // AND condition uses same field as primary condition + AND_CHANNEL_NOT_ALLOWED, // channel field not supported as AND condition + TOO_MANY_CHANNELS, // more channel hashes than MAX_CHANNEL_HASHES +}; + +// --------------------------------------------------------------------------- +// Command types returned by the parser +// --------------------------------------------------------------------------- + +enum class FilterCommand : uint8_t { + ADD, // add a new rule — result.rule is populated + DEL, // delete by id — result.rule_id is populated + LIST, + DISABLE, // disable by id — result.rule_id is populated + ENABLE, // enable by id — result.rule_id is populated + CLEAR, + MODE, // set default policy — result.mode is populated +}; + +// --------------------------------------------------------------------------- +// Parse result +// --------------------------------------------------------------------------- + +struct FilterParseResult { + FilterParseError error; + FilterCommand command; + FilterRule rule; // valid when command == ADD and error == OK + uint8_t rule_id; // valid when command == DEL / DISABLE / ENABLE + FilterMode mode; // valid when command == MODE +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Parse a full "filter ..." command string. +// 'input' must be a null-terminated C string starting after "filter ". +// The returned FilterParseResult is valid for the lifetime of the call. +FilterParseResult parseFilterCommand(const char* input); + +// Return a short human-readable description of a parse error. +const char* filterParseErrorStr(FilterParseError err); diff --git a/examples/filter test suite/FilterRule.h b/examples/filter test suite/FilterRule.h new file mode 100644 index 0000000000..728f9cdd40 --- /dev/null +++ b/examples/filter test suite/FilterRule.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +#define MAX_FILTER_RULES 8 +#define MAX_PATH_HASHES_PER_RULE 4 +#define MAX_PATH_HASH_SIZE 3 // 1, 2 or 3 bytes per hash +#define MAX_CHANNEL_HASHES 4 // OR-list for channel field + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +enum class FilterAction : uint8_t { + DROP = 0, + ALLOW = 1, +}; + +enum class FilterField : uint8_t { + ROUTE = 0, // getRouteType() — ROUTE_TYPE_* values + TYPE = 1, // getPayloadType() — PAYLOAD_TYPE_* values + HOPS = 2, // getPathHashCount() — number of hops + PATHSIZE = 3, // getPathHashSize() — bytes per path hash (1-3) + PATH = 4, // last hop in path — OR-match against hash list + CHANNEL = 5, // payload[0] — channel hash (GRP_TXT / GRP_DATA) + SNR = 6, // packet->_snr — stored in quarter-dB, compared in whole dB + RSSI = 7, // passed in at eval — dBm +}; + +enum class FilterOp : uint8_t { + EQ = 0, // equal + NEQ = 1, // not equal + GT = 2, // greater than + LT = 3, // less than +}; + +// --------------------------------------------------------------------------- +// Default policy when no rule matches +// --------------------------------------------------------------------------- + +enum class FilterMode : uint8_t { + ALLOW = 0, // default-allow (blacklist mode) + DROP = 1, // default-drop (whitelist mode) +}; + +// --------------------------------------------------------------------------- +// Rule struct +// --------------------------------------------------------------------------- + +// Sentinel value meaning "no AND condition" +#define FILTER_FIELD_NONE 0xFF + +struct FilterRule { + FilterAction action; + FilterField field; + FilterOp op; + + // Scalar comparison value. + // SNR : stored in quarter-dB (matches packet->_snr units), parser converts from whole dB + // RSSI : stored in dBm (int16_t) + // All other fields: uint8_t cast to int16_t + int16_t value; + + // Optional AND condition — active only when and_field != FILTER_FIELD_NONE. + // PATH field is not supported as an AND condition. + uint8_t and_field; // raw uint8_t so FILTER_FIELD_NONE (0xFF) fits without casting + FilterOp and_op; + int16_t and_value; + + // PATH field only: list of hashes for OR-match against the last hop in path. + // Unused slots are zero-filled. + uint8_t path_hashes[MAX_PATH_HASHES_PER_RULE][MAX_PATH_HASH_SIZE]; + uint8_t path_hash_len; // bytes per hash (1, 2 or 3) — same for all hashes in this rule + uint8_t path_hash_count; // number of valid hashes in path_hashes (1..MAX_PATH_HASHES_PER_RULE) + + // CHANNEL field only: OR-list of channel hash bytes. + // Unused slots are zero-filled. + // AND condition is not supported when field == CHANNEL. + uint8_t channel_hashes[MAX_CHANNEL_HASHES]; + uint8_t channel_hash_count; // number of valid entries (1..MAX_CHANNEL_HASHES) + + bool enabled; // false = rule is defined but temporarily inactive + bool in_use; // false = slot is empty +}; diff --git a/examples/filter test suite/Makefile b/examples/filter test suite/Makefile new file mode 100644 index 0000000000..83aa473e6f --- /dev/null +++ b/examples/filter test suite/Makefile @@ -0,0 +1,18 @@ +CXX = g++ +CXXFLAGS = -std=c++17 -Wall -Wextra -I. + +SRCS_COMMON = FilterParser.cpp ChannelFilter.cpp + +all: auto_test shell + +auto_test: $(SRCS_COMMON) auto_test.cpp + $(CXX) $(CXXFLAGS) -o auto_test $(SRCS_COMMON) auto_test.cpp + +shell: $(SRCS_COMMON) shell.cpp + $(CXX) $(CXXFLAGS) -o shell $(SRCS_COMMON) shell.cpp + +run_tests: auto_test + ./auto_test + +clean: + rm -f auto_test shell diff --git a/examples/filter test suite/README.md b/examples/filter test suite/README.md new file mode 100644 index 0000000000..a84910c7d8 --- /dev/null +++ b/examples/filter test suite/README.md @@ -0,0 +1,128 @@ +# MeshCore Filter Engine — Test Suite + +Standalone test suite for the MeshCore repeater packet filter engine. Compiles and runs on Linux without any Arduino or MeshCore dependencies. + +--- + +## Contents + +| File | Description | +|---|---| +| `FilterRule.h` | Rule struct, enums and constants | +| `FilterParser.h/.cpp` | Command parser | +| `ChannelFilter.h/.cpp` | Rule storage, evaluation, persistence and CLI dispatch | +| `mock_mesh.h` | Minimal stubs replacing Arduino/MeshCore/FILESYSTEM | +| `auto_test.cpp` | Automated test suite (pass/fail) | +| `shell.cpp` | Interactive command shell | +| `Makefile` | Build targets | + +--- + +## Build + +```bash +make # builds both auto_test and shell +make run_tests # builds and runs auto_test +make clean # removes binaries +``` + +Requires `g++` with C++11 support. + +--- + +## Automated tests + +Runs 92 tests covering parser, evaluation, persistence, AND conditions and page length. + +```bash +./auto_test +``` + +Example output: + +``` +═══════════════════════════════════════ + MeshCore Filter Engine — Test Suite +═══════════════════════════════════════ + +── Parser — valid commands + PASS add drop payload eq grptxt -> OK + PASS add allow route eq flood -> OK + ... + +═══════════════════════════════════════ + Results: 92 passed, 0 failed +═══════════════════════════════════════ +``` + +Exit code is `0` on success, `1` if any test fails. + +--- + +## Interactive shell + +Lets you type `filter` commands and test packet evaluation interactively. Rules are not persisted between sessions. + +```bash +./shell +``` + +### Filter commands + +``` +filter add drop payload eq grptxt +filter add allow route eq flood and hops lt 4 +filter disable 0 +filter enable 0 +filter list +filter list 1 +filter del 0 +filter clear +filter mode drop +filter mode allow +``` + +### Packet evaluation + +Use `eval` to test a constructed packet against the current rules: + +``` +eval route:<0-3> type:<0-15> snr: rssi: hops: [channel:] [hop:] +``` + +| Parameter | Description | +|---|---| +| `route` | Route type: `0`=tflood `1`=flood `2`=direct `3`=tdirect | +| `type` | Payload type: `0`=req `2`=txt `4`=advert `5`=grptxt `6`=grpdata ... | +| `snr` | SNR in whole dB, e.g. `-10` | +| `rssi` | RSSI in dBm, e.g. `-90` | +| `hops` | Number of hops | +| `channel` | Channel hash byte in hex, e.g. `AB` (only relevant for grptxt/grpdata) | +| `hop` | Last hop repeater hash in hex, e.g. `CD` | + +Example session: + +``` +> filter add drop channel eq 0x11 and hops gt 8 + -> OK - rule 0 added +> filter enable 0 + -> OK - rule 0 enabled +> filter list + -> mode:allow rules:1/8 + 0 drop channel eq 0x11 and hops gt 8 +> eval route:1 type:5 snr:-5 rssi:-90 hops:9 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=9 channel=0x11 + Result: *** DROPPED *** +> eval route:1 type:5 snr:-5 rssi:-90 hops:3 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=3 channel=0x11 + Result: PASSED (forwarded) +> quit +``` + +--- + +## Notes + +- Rules added via `filter add` are **disabled by default**. Use `filter enable ` to activate them. +- The `mock_mesh.h` stub provides an in-memory filesystem — rules saved during a shell session are lost on exit. +- The patched `ChannelFilter.h/.cpp` in this directory have Arduino/MeshCore includes removed. Do not copy these back into the main project — use the originals there. diff --git a/examples/filter test suite/auto_test b/examples/filter test suite/auto_test new file mode 100755 index 0000000000..6dd39d8c0c Binary files /dev/null and b/examples/filter test suite/auto_test differ diff --git a/examples/filter test suite/auto_test.cpp b/examples/filter test suite/auto_test.cpp new file mode 100644 index 0000000000..c7e3dd1d0a --- /dev/null +++ b/examples/filter test suite/auto_test.cpp @@ -0,0 +1,473 @@ +#include "mock_mesh.h" +#include "FilterRule.h" +#include "FilterParser.h" +#include "ChannelFilter.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Minimal test framework +// --------------------------------------------------------------------------- + +static int _pass = 0, _fail = 0; + +#define CHECK(desc, expr) do { \ + if (expr) { \ + printf(" PASS %s\n", desc); \ + _pass++; \ + } else { \ + printf(" FAIL %s (line %d)\n", desc, __LINE__); \ + _fail++; \ + } \ +} while(0) + +static void section(const char* name) { + printf("\n── %s\n", name); +} + +// --------------------------------------------------------------------------- +// Packet builder helpers +// --------------------------------------------------------------------------- + +static mesh::Packet makePacket(uint8_t route, uint8_t type, + int8_t snr_qdB = 0, + uint8_t channel = 0, + uint8_t hop_count = 0, uint8_t hash_size = 1, + const uint8_t* path_data = nullptr) { + mesh::Packet p; + p.setHeader(route, type); + p._snr = snr_qdB; + if (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA) { + p.payload[0] = channel; + p.payload_len = 1; + } + if (hop_count > 0 && path_data) { + p.setPath(path_data, hash_size, hop_count); + } + return p; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +static void test_parser() { + section("Parser — valid commands"); + + auto r = parseFilterCommand("add drop payload eq grptxt"); + CHECK("add drop payload eq grptxt -> OK", r.error == FilterParseError::OK); + CHECK(" command == ADD", r.command == FilterCommand::ADD); + CHECK(" action == DROP", r.rule.action == FilterAction::DROP); + CHECK(" field == TYPE", r.rule.field == FilterField::TYPE); + CHECK(" op == EQ", r.rule.op == FilterOp::EQ); + CHECK(" value == 0x05 (grptxt)", r.rule.value == 0x05); + + r = parseFilterCommand("add allow route eq flood"); + CHECK("add allow route eq flood -> OK", r.error == FilterParseError::OK); + CHECK(" action == ALLOW", r.rule.action == FilterAction::ALLOW); + CHECK(" field == ROUTE", r.rule.field == FilterField::ROUTE); + CHECK(" value == 0x01 (flood)", r.rule.value == 0x01); + + r = parseFilterCommand("add drop hops gt 5"); + CHECK("add drop hops gt 5 -> OK", r.error == FilterParseError::OK); + CHECK(" field == HOPS, op == GT, value == 5", + r.rule.field == FilterField::HOPS && r.rule.op == FilterOp::GT && r.rule.value == 5); + + r = parseFilterCommand("add drop snr lt -10"); + CHECK("add drop snr lt -10 -> OK", r.error == FilterParseError::OK); + CHECK(" SNR stored as quarter-dB (-40)", r.rule.value == -40); + + r = parseFilterCommand("add drop rssi lt -110"); + CHECK("add drop rssi lt -110 -> OK", r.error == FilterParseError::OK); + CHECK(" RSSI value == -110", r.rule.value == -110); + + r = parseFilterCommand("add drop channel eq 0xAB"); + CHECK("add drop channel eq 0xAB -> OK", r.error == FilterParseError::OK); + CHECK(" channel_hash_count == 1", r.rule.channel_hash_count == 1); + CHECK(" channel_hashes[0] == 0xAB", r.rule.channel_hashes[0] == 0xAB); + + r = parseFilterCommand("add drop channel eq 0x11 0x22 0x33"); + CHECK("channel OR-list 3 values -> OK", r.error == FilterParseError::OK); + CHECK(" channel_hash_count == 3", r.rule.channel_hash_count == 3); + CHECK(" channel_hashes[0] == 0x11", r.rule.channel_hashes[0] == 0x11); + CHECK(" channel_hashes[1] == 0x22", r.rule.channel_hashes[1] == 0x22); + CHECK(" channel_hashes[2] == 0x33", r.rule.channel_hashes[2] == 0x33); + + r = parseFilterCommand("add drop channel eq 0x11 0x22 0x33 0x44 0x55"); + CHECK("channel OR-list 5 values -> TOO_MANY_CHANNELS", r.error == FilterParseError::TOO_MANY_CHANNELS); + + r = parseFilterCommand("add drop path eq AB 12 CD"); + CHECK("add drop path eq AB 12 CD -> OK", r.error == FilterParseError::OK); + CHECK(" path_hash_count == 3", r.rule.path_hash_count == 3); + CHECK(" path_hash_len == 1", r.rule.path_hash_len == 1); + CHECK(" hash[0] == 0xAB", r.rule.path_hashes[0][0] == 0xAB); + CHECK(" hash[1] == 0x12", r.rule.path_hashes[1][0] == 0x12); + CHECK(" hash[2] == 0xCD", r.rule.path_hashes[2][0] == 0xCD); + + r = parseFilterCommand("del 3"); + CHECK("del 3 -> OK, rule_id == 3", + r.error == FilterParseError::OK && r.command == FilterCommand::DEL && r.rule_id == 3); + + r = parseFilterCommand("disable 0"); + CHECK("disable 0 -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::DISABLE); + + r = parseFilterCommand("enable 7"); + CHECK("enable 7 -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::ENABLE); + + r = parseFilterCommand("list"); + CHECK("list -> OK, page 0", + r.error == FilterParseError::OK && r.command == FilterCommand::LIST && r.rule_id == 0); + + r = parseFilterCommand("list 1"); + CHECK("list 1 -> OK, page 1", + r.error == FilterParseError::OK && r.rule_id == 1); + + r = parseFilterCommand("clear"); + CHECK("clear -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::CLEAR); + + r = parseFilterCommand("mode drop"); + CHECK("mode drop -> OK", + r.error == FilterParseError::OK && r.mode == FilterMode::DROP); + + r = parseFilterCommand("mode allow"); + CHECK("mode allow -> OK", + r.error == FilterParseError::OK && r.mode == FilterMode::ALLOW); + + section("Parser — error cases"); + + r = parseFilterCommand("add drop payload eq grptxt_TYPO"); + CHECK("unknown value -> UNKNOWN_VALUE", r.error == FilterParseError::UNKNOWN_VALUE); + + r = parseFilterCommand("add drop BADFIELD eq 5"); + CHECK("unknown field -> UNKNOWN_FIELD", r.error == FilterParseError::UNKNOWN_FIELD); + + r = parseFilterCommand("add drop payload BADOP grptxt"); + CHECK("unknown op -> UNKNOWN_OP", r.error == FilterParseError::UNKNOWN_OP); + + r = parseFilterCommand("add drop payload eq"); + CHECK("missing value -> MISSING_TOKEN", r.error == FilterParseError::MISSING_TOKEN); + + r = parseFilterCommand("del 99"); + CHECK("del out-of-range -> INVALID_RULE_ID", r.error == FilterParseError::INVALID_RULE_ID); + + r = parseFilterCommand("BADCMD"); + CHECK("unknown command -> UNKNOWN_COMMAND", r.error == FilterParseError::UNKNOWN_COMMAND); + + r = parseFilterCommand("add drop path eq AB 12 ZZZZ"); + CHECK("invalid hex in path -> INVALID_HEX", r.error == FilterParseError::INVALID_HEX); + + r = parseFilterCommand("add drop path eq AB 1234"); + CHECK("mixed hash size -> HASH_SIZE_MISMATCH", r.error == FilterParseError::HASH_SIZE_MISMATCH); + + r = parseFilterCommand("mode BADMODE"); + CHECK("unknown mode -> UNKNOWN_MODE", r.error == FilterParseError::UNKNOWN_MODE); +} + +// Helper: add a rule and immediately enable it (rules are added disabled by default) +static void addRule(ChannelFilter& f, const char* cmd, MockFS& fs) { + char reply[160]; + char enable_cmd[16]; + f.handleCommand(cmd, reply, fs); + // Extract slot id from "OK - rule N added" + int id = -1; + sscanf(reply, "OK - rule %d added", &id); + if (id >= 0) { + snprintf(enable_cmd, sizeof(enable_cmd), "enable %d", id); + f.handleCommand(enable_cmd, reply, fs); + } +} + +static void test_evaluate() { + MockFS fs; + ChannelFilter f; + + section("Evaluate — default mode allow (no rules)"); + mesh::Packet p = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + CHECK("no rules, mode allow -> pass", !f.evaluate(&p, -80)); + + section("Evaluate — route match"); + char reply[160]; + addRule(f, "add drop route eq tflood", fs); + mesh::Packet tflood = makePacket(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_TXT_MSG); + mesh::Packet flood = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("tflood packet -> DROP", f.evaluate(&tflood, -80)); + CHECK("flood packet -> PASS", !f.evaluate(&flood, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — payload type match"); + addRule(f, "add drop payload eq grptxt", fs); + mesh::Packet grptxt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet txt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("grptxt packet -> DROP", f.evaluate(&grptxt, -80)); + CHECK("txt packet -> PASS", !f.evaluate(&txt, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — hops gt"); + addRule(f, "add drop hops gt 3", fs); + uint8_t path4[4] = {0x01, 0x02, 0x03, 0x04}; + uint8_t path2[2] = {0x01, 0x02}; + mesh::Packet p4 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 4, 1, path4); + mesh::Packet p2 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 2, 1, path2); + CHECK("4 hops, rule gt 3 -> DROP", f.evaluate(&p4, -80)); + CHECK("2 hops, rule gt 3 -> PASS", !f.evaluate(&p2, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — SNR lt"); + addRule(f, "add drop snr lt -10", fs); + // SNR -12 dB = -48 quarter-dB; SNR -8 dB = -32 quarter-dB + mesh::Packet pLowSNR = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, -48); + mesh::Packet pHighSNR = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, -32); + CHECK("SNR -12 dB < -10 -> DROP", f.evaluate(&pLowSNR, -80)); + CHECK("SNR -8 dB > -10 -> PASS", !f.evaluate(&pHighSNR, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — RSSI lt"); + addRule(f, "add drop rssi lt -100", fs); + CHECK("RSSI -110 < -100 -> DROP", f.evaluate(&p, -110)); + CHECK("RSSI -80 > -100 -> PASS", !f.evaluate(&p, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — channel match"); + addRule(f, "add drop channel eq 0xAB", fs); + mesh::Packet chanAB = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0xAB); + mesh::Packet chanCD = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0xCD); + CHECK("channel 0xAB -> DROP", f.evaluate(&chanAB, -80)); + CHECK("channel 0xCD -> PASS", !f.evaluate(&chanCD, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — channel OR-list"); + addRule(f, "add drop channel eq 0xAB 0xCD 0xEF", fs); + mesh::Packet ch11 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11); + CHECK("channel 0xAB in OR-list -> DROP", f.evaluate(&chanAB, -80)); + CHECK("channel 0xCD in OR-list -> DROP", f.evaluate(&chanCD, -80)); + CHECK("channel 0x11 not in OR-list -> PASS", !f.evaluate(&ch11, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — path OR-match"); + addRule(f, "add drop path eq AB CD", fs); + uint8_t hopAB[1] = {0xAB}; + uint8_t hopCD[1] = {0xCD}; + uint8_t hopEF[1] = {0xEF}; + mesh::Packet pAB = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopAB); + mesh::Packet pCD = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopCD); + mesh::Packet pEF = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopEF); + CHECK("last hop 0xAB in list -> DROP", f.evaluate(&pAB, -80)); + CHECK("last hop 0xCD in list -> DROP", f.evaluate(&pCD, -80)); + CHECK("last hop 0xEF not in list -> PASS",!f.evaluate(&pEF, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — first-match order"); + addRule(f, "add allow payload eq grptxt", fs); + addRule(f, "add drop route eq flood", fs); + mesh::Packet grp = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet norm = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("grptxt flood: allow rule fires first -> PASS", !f.evaluate(&grp, -80)); + CHECK("txt flood: drop rule fires -> DROP", f.evaluate(&norm, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — disabled rule ignored"); + f.handleCommand("add drop payload eq grptxt", reply, fs); // added disabled — do NOT enable + mesh::Packet g = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + CHECK("added rule is disabled by default -> packet passes", !f.evaluate(&g, -80)); + f.handleCommand("enable 0", reply, fs); + CHECK("re-enabled rule -> packet drops", f.evaluate(&g, -80)); + f.handleCommand("disable 0", reply, fs); + CHECK("disabled again -> packet passes", !f.evaluate(&g, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — default mode drop"); + f.handleCommand("mode drop", reply, fs); + mesh::Packet any = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("mode drop, no rules -> DROP", f.evaluate(&any, -80)); + f.handleCommand("mode allow", reply, fs); + CHECK("mode allow, no rules -> PASS", !f.evaluate(&any, -80)); +} + +static void test_persistence() { + section("Persistence — save and reload"); + + MockFS fs; + ChannelFilter f1; + char reply[160]; + + addRule(f1, "add drop payload eq grptxt", fs); + addRule(f1, "add drop route eq tflood", fs); + f1.handleCommand("disable 0", reply, fs); + f1.handleCommand("mode drop", reply, fs); + + ChannelFilter f2; + f2.load(fs); + + mesh::Packet grptxt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet tflood = makePacket(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_TXT_MSG); + mesh::Packet norm = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + + CHECK("rule 0 disabled after reload -> grptxt drops (default policy)", f2.evaluate(&grptxt, -80)); + CHECK("rule 1 active after reload -> tflood drops", f2.evaluate(&tflood, -80)); + CHECK("mode drop after reload -> norm drops", f2.evaluate(&norm, -80)); + + ChannelFilter f3; + f3.handleCommand("add drop payload eq grptxt", reply, fs); // added disabled + CHECK("added disabled, mode allow -> grptxt passes", !f3.evaluate(&grptxt, -80)); + + section("Persistence — magic and version validation"); + + // Old format file: starts with mode byte (no magic) + MockFS fs_old; + { + uint8_t old_file[3] = { 0x00, 0x00, 0x00 }; // mode=allow + garbage, no magic + auto f = fs_old.open(FILTER_RULES_FILE, "w"); + f.write(old_file, sizeof(old_file)); + f.close(); + } + ChannelFilter f_old; + f_old.load(fs_old); // should discard — no magic + CHECK("old format (no magic) discarded -> mode defaults to allow", !f_old.evaluate(&norm, -80)); + + // File with correct magic but old version (1) + MockFS fs_v1; + { + uint8_t v1_file[3] = { 0xFC, 0x01, 0x01 }; // magic + version=1 + mode=drop + auto f = fs_v1.open(FILTER_RULES_FILE, "w"); + f.write(v1_file, sizeof(v1_file)); + f.close(); + } + ChannelFilter f_v1; + f_v1.load(fs_v1); // should discard — version < 2 + CHECK("version 1 file discarded -> mode defaults to allow", !f_v1.evaluate(&norm, -80)); + + // Verify save writes correct magic and version + MockFS fs_save; + ChannelFilter f_save; + f_save.handleCommand("mode drop", reply, fs_save); + f_save.save(fs_save); + { + auto f = fs_save.open(FILTER_RULES_FILE, "r"); + uint8_t magic = 0, version = 0; + f.read(&magic, 1); + f.read(&version, 1); + f.close(); + CHECK("saved file has correct magic", magic == 0xFC); + CHECK("saved file has correct version", version == 3); + } +} + +static void test_and_condition() { + MockFS fs; + ChannelFilter f; + char reply[160]; + + section("Parser — AND condition"); + + auto r = parseFilterCommand("add drop channel eq 0x11 and hops gt 8"); + CHECK("AND parse -> OK", r.error == FilterParseError::OK); + CHECK(" primary field == CHANNEL", r.rule.field == FilterField::CHANNEL); + CHECK(" channel_hashes[0] == 0x11", r.rule.channel_hashes[0] == 0x11); + CHECK(" and_field == HOPS", r.rule.and_field == (uint8_t)FilterField::HOPS); + CHECK(" and_op == GT", r.rule.and_op == FilterOp::GT); + CHECK(" and_value == 8", r.rule.and_value == 8); + + r = parseFilterCommand("add drop snr lt -10 and rssi lt -100"); + CHECK("AND snr+rssi -> OK", r.error == FilterParseError::OK); + CHECK(" and_field == RSSI", r.rule.and_field == (uint8_t)FilterField::RSSI); + CHECK(" and_value == -100", r.rule.and_value == -100); + + r = parseFilterCommand("add drop path eq AB and hops gt 3"); + CHECK("AND path+hops -> OK", r.error == FilterParseError::OK); + CHECK(" path primary intact", r.rule.path_hash_count == 1); + CHECK(" and_field == HOPS", r.rule.and_field == (uint8_t)FilterField::HOPS); + + r = parseFilterCommand("add drop channel eq 0x11 and path eq AB"); + CHECK("AND with path -> AND_PATH_NOT_ALLOWED", r.error == FilterParseError::AND_PATH_NOT_ALLOWED); + + r = parseFilterCommand("add drop channel eq 0x11 and channel eq 0x22"); + CHECK("AND duplicate field -> AND_DUPLICATE_FIELD",r.error == FilterParseError::AND_DUPLICATE_FIELD); + + section("Evaluate — AND condition"); + + // Rule: drop channel eq 0x11 and hops gt 3 + addRule(f, "add drop channel eq 0x11 and hops gt 3", fs); + + uint8_t path4[4] = {0x01, 0x02, 0x03, 0x04}; + uint8_t path2[2] = {0x01, 0x02}; + + // Both conditions true -> DROP + mesh::Packet p_both = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11, 4, 1, path4); + CHECK("channel 0x11 AND hops=4 > 3 -> DROP", f.evaluate(&p_both, -80)); + + // Channel matches, hops does not -> PASS + mesh::Packet p_ch_only = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11, 2, 1, path2); + CHECK("channel 0x11 AND hops=2 not > 3 -> PASS", !f.evaluate(&p_ch_only, -80)); + + // Hops match, channel does not -> PASS + mesh::Packet p_hop_only = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x22, 4, 1, path4); + CHECK("channel 0x22 AND hops=4, ch mismatch -> PASS", !f.evaluate(&p_hop_only, -80)); + + f.handleCommand("clear", reply, fs); + + section("List — AND condition display"); + + f.handleCommand("add drop channel eq 0x11 and hops gt 8", reply, fs, nullptr); + f.handleCommand("list", reply, fs, (ClientInfo*)1); // remote = paginated + CHECK("list shows 'and' keyword", strstr(reply, "and") != nullptr); + CHECK("list shows 'hops'", strstr(reply, "hops") != nullptr); + CHECK("list shows '8'", strstr(reply, "8") != nullptr); + CHECK("list within 138 chars", strlen(reply) <= 138); + f.handleCommand("clear", reply, fs, nullptr); +} + +static void test_list_length() { + section("List output — page length within 138 chars"); + + MockFS fs; + ChannelFilter f; + char reply[160]; + + // Fill with worst-case rules + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + + for (int page = 0; ; page++) { + char pr[16]; + snprintf(pr, sizeof(pr), "list %d", page); + f.handleCommand(pr, reply, fs); + int len = strlen(reply); + char desc[64]; + snprintf(desc, sizeof(desc), "page %d length %d <= 138", page, len); + CHECK(desc, len <= 138); + // Stop when no more pages hinted + if (strstr(reply, "-> filter list") == nullptr) break; + if (page > 10) break; // safety + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +int main() { + printf("═══════════════════════════════════════\n"); + printf(" MeshCore Filter Engine — Test Suite\n"); + printf("═══════════════════════════════════════\n"); + + test_parser(); + test_evaluate(); + test_persistence(); + test_and_condition(); + test_list_length(); + + printf("\n═══════════════════════════════════════\n"); + printf(" Results: %d passed, %d failed\n", _pass, _fail); + printf("═══════════════════════════════════════\n"); + + return (_fail == 0) ? 0 : 1; +} diff --git a/examples/filter test suite/mock_mesh.h b/examples/filter test suite/mock_mesh.h new file mode 100644 index 0000000000..110b866c07 --- /dev/null +++ b/examples/filter test suite/mock_mesh.h @@ -0,0 +1,165 @@ +#pragma once + +// --------------------------------------------------------------------------- +// Minimal stubs so FilterParser.cpp and ChannelFilter.cpp compile on Linux +// without Arduino/MeshCore headers. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +// Silence debug prints +#define MESH_DEBUG_PRINTLN(x) do {} while(0) + +// Route type constants (mirrors Packet.h) +#define ROUTE_TYPE_TRANSPORT_FLOOD 0x00 +#define ROUTE_TYPE_FLOOD 0x01 +#define ROUTE_TYPE_DIRECT 0x02 +#define ROUTE_TYPE_TRANSPORT_DIRECT 0x03 + +// Payload type constants (mirrors Packet.h) +#define PAYLOAD_TYPE_REQ 0x00 +#define PAYLOAD_TYPE_RESPONSE 0x01 +#define PAYLOAD_TYPE_TXT_MSG 0x02 +#define PAYLOAD_TYPE_ACK 0x03 +#define PAYLOAD_TYPE_ADVERT 0x04 +#define PAYLOAD_TYPE_GRP_TXT 0x05 +#define PAYLOAD_TYPE_GRP_DATA 0x06 +#define PAYLOAD_TYPE_ANON_REQ 0x07 +#define PAYLOAD_TYPE_PATH 0x08 +#define PAYLOAD_TYPE_TRACE 0x09 +#define PAYLOAD_TYPE_MULTIPART 0x0A +#define PAYLOAD_TYPE_CONTROL 0x0B +#define PAYLOAD_TYPE_RAW_CUSTOM 0x0F + +#define PH_ROUTE_MASK 0x03 +#define PH_TYPE_SHIFT 2 +#define PH_TYPE_MASK 0x0F +#define PH_VER_SHIFT 6 + +#define MAX_PATH_SIZE 64 +#define MAX_PACKET_PAYLOAD 200 + +namespace mesh { + +class Packet { +public: + uint8_t header; + uint16_t payload_len; + uint16_t path_len; + uint8_t path[MAX_PATH_SIZE]; + uint8_t payload[MAX_PACKET_PAYLOAD]; + int8_t _snr; // quarter-dB + + Packet() { memset(this, 0, sizeof(*this)); } + + uint8_t getRouteType() const { return header & PH_ROUTE_MASK; } + uint8_t getPayloadType() const { return (header >> PH_TYPE_SHIFT) & PH_TYPE_MASK; } + uint8_t getPathHashSize() const { return (path_len >> 6) + 1; } + uint8_t getPathHashCount()const { return path_len & 63; } + float getSNR() const { return ((float)_snr) / 4.0f; } + + // Helper: set route + payload type in header + void setHeader(uint8_t route, uint8_t type) { + header = (route & PH_ROUTE_MASK) | ((type & PH_TYPE_MASK) << PH_TYPE_SHIFT); + } + + // Helper: set path with N hops of hash_size bytes each + void setPath(const uint8_t* data, uint8_t hash_size, uint8_t hop_count) { + path_len = ((hash_size - 1) << 6) | (hop_count & 63); + memcpy(path, data, hop_count * hash_size); + } +}; + +} // namespace mesh + +// --------------------------------------------------------------------------- +// Minimal in-memory FILESYSTEM mock +// --------------------------------------------------------------------------- + +#include +#include +#include + +class MockFile { +public: + std::vector* _buf; + size_t _pos; + bool _write; + bool _valid; + + MockFile() : _buf(nullptr), _pos(0), _write(false), _valid(false) {} + + explicit operator bool() const { return _valid; } + + size_t read(uint8_t* dest, size_t len) { + if (!_valid || _write) return 0; + size_t avail = _buf->size() - _pos; + size_t n = len < avail ? len : avail; + memcpy(dest, _buf->data() + _pos, n); + _pos += n; + return n; + } + + size_t write(const uint8_t* src, size_t len) { + if (!_valid || !_write) return 0; + _buf->insert(_buf->end(), src, src + len); + return len; + } + + void close() { _valid = false; } +}; + +class MockFS { + std::map> _files; +public: + MockFile open(const char* path, const char* mode = "r") { + MockFile f; + bool writing = (mode && mode[0] == 'w'); + f._write = writing; + if (writing) { + _files[path].clear(); + f._buf = &_files[path]; + f._valid = true; + } else { + auto it = _files.find(path); + if (it != _files.end()) { + f._buf = &it->second; + f._valid = true; + } + } + f._pos = 0; + return f; + } + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + void clear() { _files.clear(); } +}; + +// Minimal Serial stub for local list output +struct MockSerial { + void printf(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + vprintf(fmt, args); + va_end(args); + } +}; +inline MockSerial Serial; + +// Minimal ClientInfo stub — only used as a pointer (nullptr = local, non-null = remote) +struct ClientInfo {}; + +using FILESYSTEM = MockFS; +// File type alias +using File = MockFile; diff --git a/examples/filter test suite/readme.md b/examples/filter test suite/readme.md new file mode 100644 index 0000000000..dd66537bae --- /dev/null +++ b/examples/filter test suite/readme.md @@ -0,0 +1,128 @@ +# MeshCore Filter Engine — Test Suite + +Standalone test suite for the MeshCore repeater packet filter engine. Compiles and runs on Linux without any Arduino or MeshCore dependencies. + +--- + +## Contents + +| File | Description | +|---|---| +| `FilterRule.h` | Rule struct, enums and constants | +| `FilterParser.h/.cpp` | Command parser | +| `ChannelFilter.h/.cpp` | Rule storage, evaluation, persistence and CLI dispatch | +| `mock_mesh.h` | Minimal stubs replacing Arduino/MeshCore/FILESYSTEM | +| `auto_test.cpp` | Automated test suite (pass/fail) | +| `shell.cpp` | Interactive command shell | +| `Makefile` | Build targets | + +--- + +## Build + +```bash +make # builds both auto_test and shell +make run_tests # builds and runs auto_test +make clean # removes binaries +``` + +Requires `g++` with C++11 support. + +--- + +## Automated tests + +Runs 92 tests covering parser, evaluation, persistence, AND conditions and page length. + +```bash +./auto_test +``` + +Example output: + +``` +═══════════════════════════════════════ + MeshCore Filter Engine — Test Suite +═══════════════════════════════════════ + +── Parser — valid commands + PASS add drop payload eq grptxt -> OK + PASS add allow route eq flood -> OK + ... + +═══════════════════════════════════════ + Results: 92 passed, 0 failed +═══════════════════════════════════════ +``` + +Exit code is `0` on success, `1` if any test fails. + +--- + +## Interactive shell + +Lets you type `filter` commands and test packet evaluation interactively. Rules are not persisted between sessions. + +```bash +./shell +``` + +### Filter commands + +``` +filter add drop payload eq grptxt +filter add allow route eq flood and hops lt 4 +filter disable 0 +filter enable 0 +filter list +filter list 1 +filter del 0 +filter clear +filter mode drop +filter mode allow +``` + +### Packet evaluation + +Use `eval` to test a constructed packet against the current rules: + +``` +eval route:<0-3> type:<0-15> snr: rssi: hops: [channel:] [hop:] +``` + +| Parameter | Description | +|---|---| +| `route` | Route type: `0`=tflood `1`=flood `2`=direct `3`=tdirect | +| `type` | Payload type: `0`=req `2`=txt `4`=advert `5`=grptxt `6`=grpdata ... | +| `snr` | SNR in whole dB, e.g. `-10` | +| `rssi` | RSSI in dBm, e.g. `-90` | +| `hops` | Number of hops | +| `channel` | Channel hash byte in hex, e.g. `AB` (only relevant for grptxt/grpdata) | +| `hop` | Last hop repeater hash in hex, e.g. `CD` | + +Example session: + +``` +> filter add drop channel eq 0x11 and hops gt 8 + -> OK - rule 0 added +> filter enable 0 + -> OK - rule 0 enabled +> filter list + -> mode:allow rules:1/8 + 0 drop channel eq 0x11 and hops gt 8 +> eval route:1 type:5 snr:-5 rssi:-90 hops:9 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=9 channel=0x11 + Result: *** DROPPED *** +> eval route:1 type:5 snr:-5 rssi:-90 hops:3 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=3 channel=0x11 + Result: PASSED (forwarded) +> quit +``` + +--- + +## Notes + +- Rules added via `filter add` are **disabled by default**. Use `filter enable ` to activate them. +- The `mock_mesh.h` stub provides an in-memory filesystem — rules saved during a shell session are lost on exit. +- The patched `ChannelFilter.h/.cpp` in this directory have Arduino/MeshCore includes removed. Do not copy these back into the main project — use the originals there. \ No newline at end of file diff --git a/examples/filter test suite/shell b/examples/filter test suite/shell new file mode 100755 index 0000000000..651eae22b4 Binary files /dev/null and b/examples/filter test suite/shell differ diff --git a/examples/filter test suite/shell.cpp b/examples/filter test suite/shell.cpp new file mode 100644 index 0000000000..911cfef6c5 --- /dev/null +++ b/examples/filter test suite/shell.cpp @@ -0,0 +1,153 @@ +#include "mock_mesh.h" +#include "FilterRule.h" +#include "FilterParser.h" +#include "ChannelFilter.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Interactive shell — type "filter ..." commands and see output +// --------------------------------------------------------------------------- + +static void print_help() { + printf("\n"); + printf(" Commands:\n"); + printf(" filter add \n"); + printf(" filter del \n"); + printf(" filter disable / enable \n"); + printf(" filter list [page]\n"); + printf(" filter clear\n"); + printf(" filter mode allow|drop\n"); + printf("\n"); + printf(" Test packet evaluation:\n"); + printf(" eval route:<0-3> type:<0-15> snr: rssi: hops: [channel:] [hop:]\n"); + printf(" route: 0=tflood 1=flood 2=direct 3=tdirect\n"); + printf(" type: 0=req 2=txt 4=advert 5=grptxt 6=grpdata ...\n"); + printf("\n"); + printf(" eval example: eval route:1 type:5 snr:-5 rssi:-90 hops:2 channel:AB hop:CD\n"); + printf("\n"); + printf(" help — show this message\n"); + printf(" quit — exit\n"); + printf("\n"); +} + +// Simple key:value parser for eval command +static bool getIntArg(const char* line, const char* key, long* out) { + const char* p = strstr(line, key); + if (!p) return false; + p += strlen(key); + char* end; + *out = strtol(p, &end, 0); + return end != p; +} + +static bool getHexArg(const char* line, const char* key, uint8_t* out) { + const char* p = strstr(line, key); + if (!p) return false; + p += strlen(key); + // skip optional 0x + if (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) p += 2; + char* end; + long v = strtol(p, &end, 16); + if (end == p) return false; + *out = (uint8_t)v; + return true; +} + +static void handle_eval(const char* line, ChannelFilter& f) { + long route = 1, type = 2, snr_db = 0, rssi = -80, hops = 0; + uint8_t channel = 0, hop_hash = 0; + bool has_channel = false, has_hop = false; + + getIntArg(line, "route:", &route); + getIntArg(line, "type:", &type); + getIntArg(line, "snr:", &snr_db); + getIntArg(line, "rssi:", &rssi); + getIntArg(line, "hops:", &hops); + has_channel = getHexArg(line, "channel:", &channel); + has_hop = getHexArg(line, "hop:", &hop_hash); + + mesh::Packet pkt; + pkt.setHeader((uint8_t)route, (uint8_t)type); + pkt._snr = (int8_t)(snr_db * 4); + + if (has_channel) { + pkt.payload[0] = channel; + pkt.payload_len = 1; + } + + uint8_t path_data[64]; + memset(path_data, 0, sizeof(path_data)); + if (hops > 0) { + // Fill path with zeros except last hop + for (int i = 0; i < hops - 1; i++) path_data[i] = 0x00; + path_data[hops - 1] = has_hop ? hop_hash : 0x00; + pkt.setPath(path_data, 1, (uint8_t)hops); + } + + bool dropped = f.evaluate(&pkt, (int16_t)rssi); + + printf(" Packet: route=%ld type=%ld snr=%lddB rssi=%lddBm hops=%ld", + route, type, snr_db, rssi, hops); + if (has_channel) printf(" channel=0x%02X", channel); + if (has_hop) printf(" last_hop=0x%02X", hop_hash); + printf("\n"); + printf(" Result: %s\n", dropped ? "*** DROPPED ***" : "PASSED (forwarded)"); +} + +int main() { + MockFS fs; + ChannelFilter filter; + + printf("═══════════════════════════════════════\n"); + printf(" MeshCore Filter Engine — Interactive Shell\n"); + printf(" (rules are not persisted between sessions)\n"); + printf("═══════════════════════════════════════\n"); + print_help(); + + char line[256]; + while (true) { + printf("> "); + fflush(stdout); + + if (!fgets(line, sizeof(line), stdin)) break; + + // Strip trailing newline + size_t len = strlen(line); + while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) { + line[--len] = '\0'; + } + + if (len == 0) continue; + + if (strcmp(line, "quit") == 0 || strcmp(line, "exit") == 0) { + printf("Bye.\n"); + break; + } + + if (strcmp(line, "help") == 0) { + print_help(); + continue; + } + + if (strncmp(line, "eval", 4) == 0) { + handle_eval(line + 4, filter); + continue; + } + + if (strncmp(line, "filter", 6) == 0 && + (line[6] == ' ' || line[6] == '\0')) { + char reply[160]; + const char* args = (line[6] == ' ') ? line + 7 : ""; + filter.handleCommand(args, reply, fs); + printf(" -> %s\n", reply); + continue; + } + + printf(" Unknown command. Type 'help' for usage.\n"); + } + + return 0; +} diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp new file mode 100644 index 0000000000..c0ee443d29 --- /dev/null +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -0,0 +1,574 @@ +#include "MyMesh.h" +#include "ChannelFilter.h" +#include +#include + +// --------------------------------------------------------------------------- +// Persistence layout (binary blob, fixed size): +// [uint8_t magic = FILTER_FILE_MAGIC] +// [uint8_t version = FILTER_FILE_VERSION] +// [uint8_t mode] +// [FilterRule * MAX_FILTER_RULES] +// --------------------------------------------------------------------------- + +ChannelFilter::ChannelFilter() { + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +void ChannelFilter::load(FILESYSTEM &fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "r"); +#else + File f = fs.open(FILTER_RULES_FILE); +#endif + if (!f) return; + + // Validate magic byte — old or corrupt file starts fresh + uint8_t magic; + if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { + f.close(); + return; + } + + // Validate version — discard files older than current version + uint8_t version; + if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { + f.close(); + return; + } + + // Read mode byte + uint8_t mode_byte; + if (f.read(&mode_byte, 1) != 1) { + f.close(); + return; + } + // Validate mode byte — default to ALLOW if file is corrupt + _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + ? (FilterMode)mode_byte + : FilterMode::ALLOW; + + // Check read length — if truncated, zero remaining slots (in_use=false = harmless) + size_t bytes_read = f.read((uint8_t *)_rules, sizeof(_rules)); + if (bytes_read < sizeof(_rules)) { + memset((uint8_t *)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); + } + f.close(); +} + +void ChannelFilter::save(FILESYSTEM &fs) const { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "w"); +#else + if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, "w"); +#endif + if (!f) return; + + uint8_t header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; + f.write(header, sizeof(header)); + f.write((const uint8_t *)_rules, sizeof(_rules)); + f.close(); +} + +// --------------------------------------------------------------------------- +// Evaluation helpers +// --------------------------------------------------------------------------- + +static bool applyOp(FilterOp op, int16_t pkt_val, int16_t rule_val) { + switch (op) { + case FilterOp::EQ: + return pkt_val == rule_val; + case FilterOp::NEQ: + return pkt_val != rule_val; + case FilterOp::GT: + return pkt_val > rule_val; + case FilterOp::LT: + return pkt_val < rule_val; + default: + return false; + } +} + +bool ChannelFilter::_ruleMatches(const FilterRule &rule, const mesh::Packet *pkt, int16_t rssi) const { + // PATH field has its own OR-list logic — handle separately + if (rule.field == FilterField::PATH) { + uint8_t hash_size = pkt->getPathHashSize(); + uint8_t hash_count = pkt->getPathHashCount(); + + if (hash_count == 0) return false; + if (hash_size != rule.path_hash_len) return false; + + uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; + if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; + + const uint8_t *last_hop = pkt->path + last_hop_offset; + bool found = false; + for (uint8_t i = 0; i < rule.path_hash_count; i++) { + if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { + found = true; + break; + } + } + + bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; + if (!primary_match) return false; + + // AND condition (PATH as primary can still have a scalar AND) + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // CHANNEL field has OR-list logic + if (rule.field == FilterField::CHANNEL) { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; // GRP_TXT / GRP_DATA only + if (pkt->payload_len < 1) return false; + + uint8_t ch = pkt->payload[0]; + bool found = false; + for (uint8_t i = 0; i < rule.channel_hash_count; i++) { + if (rule.channel_hashes[i] == ch) { + found = true; + break; + } + } + + bool primary_match; + if (rule.op == FilterOp::EQ) primary_match = found; + else if (rule.op == FilterOp::NEQ) primary_match = !found; + else return false; // GT/LT not meaningful for channel OR-list + + if (!primary_match) return false; + + // AND condition for channel primary + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + + // AND condition if present + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + } + + return true; +} + +// Evaluate a single scalar condition against a packet. +// PATH field is not handled here — it has its own block in the switch above. +bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet *pkt, int16_t rssi) const { + switch (field) { + case FilterField::ROUTE: + return applyOp(op, (int16_t)pkt->getRouteType(), val); + + case FilterField::TYPE: + return applyOp(op, (int16_t)pkt->getPayloadType(), val); + + case FilterField::HOPS: + return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + + case FilterField::PATHSIZE: + return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + + case FilterField::CHANNEL: { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; + if (pkt->payload_len < 1) return false; + // OR-match against channel_hashes list — find the rule via pointer arithmetic + // Note: val is unused for CHANNEL — match logic uses the rule struct directly. + // This case should not be reached via _evalScalar for channel primary field. + // It remains here as a safety fallback using val as single-value compare. + return applyOp(op, (int16_t)pkt->payload[0], val); + } + + case FilterField::SNR: + return applyOp(op, (int16_t)pkt->_snr, val); + + case FilterField::RSSI: + return applyOp(op, rssi, val); + + default: + return false; + } +} + +bool ChannelFilter::evaluate(const mesh::Packet *pkt, int16_t rssi) const { + if (!pkt) return false; // null guard — pass unknown packets rather than crash + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + const FilterRule &rule = _rules[i]; + if (!rule.in_use || !rule.enabled) continue; + + if (_ruleMatches(rule, pkt, rssi)) { + return rule.action == FilterAction::DROP; + } + } + // No rule matched — apply default policy + return _mode == FilterMode::DROP; +} + +// --------------------------------------------------------------------------- +// Slot helpers +// --------------------------------------------------------------------------- + +int ChannelFilter::_firstFreeSlot() const { + for (int i = 0; i < MAX_FILTER_RULES; i++) { + if (!_rules[i].in_use) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// List formatting +// --------------------------------------------------------------------------- + +// Return a short token string for a FilterField value +static const char *fieldStr(FilterField f) { + switch (f) { + case FilterField::ROUTE: + return "route"; + case FilterField::TYPE: + return "payload"; + case FilterField::HOPS: + return "hops"; + case FilterField::PATHSIZE: + return "pathsize"; + case FilterField::PATH: + return "path"; + case FilterField::CHANNEL: + return "channel"; + case FilterField::SNR: + return "snr"; + case FilterField::RSSI: + return "rssi"; + default: + return "?"; + } +} + +static const char *opStr(FilterOp op) { + switch (op) { + case FilterOp::EQ: + return "eq"; + case FilterOp::NEQ: + return "neq"; + case FilterOp::GT: + return "gt"; + case FilterOp::LT: + return "lt"; + default: + return "?"; + } +} + +// Translate ROUTE_TYPE_* numeric value to token string +static const char *routeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "tflood"; + case 0x01: + return "flood"; + case 0x02: + return "direct"; + case 0x03: + return "tdirect"; + default: + return "?"; + } +} + +// Translate PAYLOAD_TYPE_* numeric value to token string +static const char *payloadTypeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "req"; + case 0x01: + return "resp"; + case 0x02: + return "txt"; + case 0x03: + return "ack"; + case 0x04: + return "advert"; + case 0x05: + return "grptxt"; + case 0x06: + return "grpdata"; + case 0x07: + return "anonreq"; + case 0x08: + return "path"; + case 0x09: + return "trace"; + case 0x0A: + return "multi"; + case 0x0B: + return "ctrl"; + case 0x0F: + return "raw"; + default: + return "?"; + } +} + +static void formatRuleValue(const FilterRule &rule, char *out, int outlen) { + if (outlen <= 0) return; + if (rule.field == FilterField::PATH) { + int pos = 0; + for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { + pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); + } + } + out[pos] = '\0'; + } else if (rule.field == FilterField::ROUTE) { + snprintf(out, outlen, "%s", routeValueStr(rule.value)); + } else if (rule.field == FilterField::TYPE) { + snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); + } else if (rule.field == FilterField::CHANNEL) { + int pos = 0; + for (uint8_t i = 0; i < rule.channel_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + pos += snprintf(out + pos, outlen - pos, "0x%02X", rule.channel_hashes[i]); + } + out[pos] = '\0'; + } else if (rule.field == FilterField::SNR) { + // Convert stored quarter-dB back to whole dB for display + snprintf(out, outlen, "%d", (int)(rule.value / 4)); + } else { + snprintf(out, outlen, "%d", (int)rule.value); + } +} + +// Maximum reply length — stay safely below the 138-char packet limit +#define FILTER_REPLY_BUDGET 128 +// Reserved for header and hint line +#define FILTER_REPLY_HEADER_MAX 32 +#define FILTER_REPLY_HINT_LEN 18 // "-> filter list N\0" + +// Format a single rule line into buf (null-terminated). Returns number of chars written. +static int formatRuleLine(const FilterRule &rule, uint8_t idx, char *buf, int buflen) { + char val_buf[32]; + formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); + + char and_buf[48] = ""; + if (rule.and_field != FILTER_FIELD_NONE) { + char and_val_buf[32]; + FilterField af = (FilterField)rule.and_field; + if (af == FilterField::ROUTE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); + } else if (af == FilterField::TYPE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); + } else if (af == FilterField::CHANNEL) { + snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); + } else if (af == FilterField::SNR) { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); + } else { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); + } + snprintf(and_buf, sizeof(and_buf), " and %s %s %s", + fieldStr(af), opStr(rule.and_op), and_val_buf); + } + + return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", + idx, + rule.enabled ? "" : "*", + rule.action == FilterAction::DROP ? "drop" : "allow", + fieldStr(rule.field), + opStr(rule.op), + val_buf, + and_buf + ); +} + +void ChannelFilter::_listRules(char *reply, uint8_t page, bool is_remote) const { + // Count in-use rules and collect their indexes + uint8_t indexes[MAX_FILTER_RULES]; + uint8_t total = 0; + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + if (_rules[i].in_use) indexes[total++] = i; + } + + const char *mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + + if (!is_remote) { + Serial.printf("mode:%s rules:%d/%d\n", mode_str, total, MAX_FILTER_RULES); + if (total > 0) { + for (uint8_t i = 0; i < total; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + Serial.printf("%s", line_buf); + } + } + reply[0] = '\0'; + return; + } else { + // Pre-scan: determine page boundaries dynamically based on actual line lengths. + // Each page gets as many rules as fit within FILTER_REPLY_BUDGET minus header and hint. + uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page + uint8_t num_pages = 0; + page_start[0] = 0; + + { + uint8_t i = 0; + while (i < total) { + // Available budget for rule lines on this page + int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; + uint8_t page_end = i; + + while (page_end < total) { + char line_buf[80]; + int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], + line_buf, sizeof(line_buf)); + if (budget - line_len < 0) break; // doesn't fit + budget -= line_len; + page_end++; + } + + // Safety: always advance at least one rule to avoid infinite loop + if (page_end == i) page_end = i + 1; + + num_pages++; + i = page_end; + page_start[num_pages] = i; + } + } + + if (total == 0) num_pages = 1; + + if (page >= num_pages) { + snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); + return; + } + + // Write header + int pos; + if (num_pages > 1) { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", + mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); + } else { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", + mode_str, total, MAX_FILTER_RULES); + } + + if (total == 0) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); + return; + } + + // Write rule lines for this page + uint8_t start = page_start[page]; + uint8_t end = page_start[page + 1]; + + for (uint8_t i = start; i < end; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); + } + + // Hint if more pages follow + if (page + 1 < num_pages) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); + } + } +} + +// --------------------------------------------------------------------------- +// CLI dispatch +// --------------------------------------------------------------------------- + +void ChannelFilter::handleCommand(const char *args, char *reply, FILESYSTEM &fs, ClientInfo *sender) { + bool is_remote = (sender != NULL); // Check if commands are from Serial (ClientInfo = NULL). + FilterParseResult res = parseFilterCommand(args); + + if (res.error != FilterParseError::OK) { + snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); + return; + } + + switch (res.command) { + case FilterCommand::ADD: { + int slot = _firstFreeSlot(); + if (slot < 0) { + snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); + return; + } + _rules[slot] = res.rule; + save(fs); + snprintf(reply, 80, "OK - rule %d added", slot); + break; + } + + case FilterCommand::DEL: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + memset(&_rules[id], 0, sizeof(FilterRule)); + save(fs); + snprintf(reply, 80, "OK - rule %d deleted", id); + break; + } + + case FilterCommand::DISABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = false; + save(fs); + snprintf(reply, 80, "OK - rule %d disabled", id); + break; + } + + case FilterCommand::ENABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = true; + save(fs); + snprintf(reply, 80, "OK - rule %d enabled", id); + break; + } + + case FilterCommand::LIST: + _listRules(reply, res.rule_id, is_remote); + break; + + case FilterCommand::CLEAR: + memset(_rules, 0, sizeof(_rules)); + save(fs); + snprintf(reply, 80, "OK - all rules cleared"); + break; + + case FilterCommand::MODE: + _mode = res.mode; + save(fs); + snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); + break; + } +} \ No newline at end of file diff --git a/examples/simple_repeater/ChannelFilter.h b/examples/simple_repeater/ChannelFilter.h new file mode 100644 index 0000000000..56332ea563 --- /dev/null +++ b/examples/simple_repeater/ChannelFilter.h @@ -0,0 +1,63 @@ +#pragma once + +// NOTE: This header relies on FILESYSTEM being defined before inclusion. +// MyMesh.h includes the platform-specific filesystem headers before including +// this file, so FILESYSTEM is always defined in that context. + +#include "FilterRule.h" +#include "FilterParser.h" +#include + +// Persistence file path +#define FILTER_RULES_FILE "/filter_rules.bin" + +// File format: [magic] [version] [mode] [FilterRule * MAX_FILTER_RULES] +// magic : 0xFC — identifies this as a valid filter rules file +// version: 2 — current struct version; files with version < 2 are discarded +#define FILTER_FILE_MAGIC 0xFC +#define FILTER_FILE_VERSION 3 + +// --------------------------------------------------------------------------- +// ChannelFilter +// --------------------------------------------------------------------------- + +class ChannelFilter { +public: + ChannelFilter(); + + // --- Lifecycle ---------------------------------------------------------- + + // Load rules and mode from filesystem. Call once at startup. + void load(FILESYSTEM& fs); + + // Save rules and mode to filesystem. + void save(FILESYSTEM& fs) const; + + // --- Evaluation --------------------------------------------------------- + + // Evaluate all active rules against a received packet. + // 'rssi' is passed separately as it lives in the radio driver, not in Packet. + // Returns true if the packet should be DROPPED, false if it should pass. + bool evaluate(const mesh::Packet* pkt, int16_t rssi) const; + + // --- CLI dispatch ------------------------------------------------------- + + // Handle a "filter ..." command string (everything after "filter "). + // Writes a human-readable result into 'reply' (assumed >= 80 bytes). + // sender == nullptr means local Serial CLI; sender != nullptr means remote CLI (paginates output). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs, ClientInfo* sender = nullptr); + +private: + FilterRule _rules[MAX_FILTER_RULES]; + FilterMode _mode; // default policy when no rule matches + + // --- Rule helpers ------------------------------------------------------- + + int _firstFreeSlot() const; + bool _ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const; + bool _evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const; + + // --- list command ------------------------------------------------------- + void _listRules(char* reply, uint8_t page, bool is_remote) const; +}; \ No newline at end of file diff --git a/examples/simple_repeater/FILTER.md b/examples/simple_repeater/FILTER.md new file mode 100644 index 0000000000..04c641d30c --- /dev/null +++ b/examples/simple_repeater/FILTER.md @@ -0,0 +1,314 @@ +# MeshCore Repeater — Packet Filter Engine + +The filter engine allows fine-grained control over which packets a repeater forwards. Rules are evaluated in order — the first matching rule wins. If no rule matches, the default policy (`mode`) applies. + +Rules survive reboot and are stored in `/filter_rules.bin`. The file includes a magic byte and version number — if the format changes between firmware versions, the file is automatically discarded and rules start fresh. + +--- + +## Commands + +All commands are prefixed with `filter`. + +| Command | Description | +|---|---| +| `filter add [and ]` | Add a new rule | +| `filter del ` | Delete rule by ID | +| `filter disable ` | Temporarily disable a rule | +| `filter enable ` | Re-enable a disabled rule | +| `filter list` | List rules — page 0 | +| `filter list ` | List rules — specific page (0-indexed) | +| `filter clear` | Delete all rules | +| `filter mode ` | Set default policy when no rule matches | + +--- + +## Actions + +| Token | Description | +|---|---| +| `drop` | Drop the packet — do not forward | +| `allow` | Allow the packet — forward it | + +--- + +## Fields + +| Token | Matches | Operators | +|---|---|---| +| `route` | Route type of the packet | `eq` `neq` | +| `payload` | Payload type of the packet | `eq` `neq` `gt` `lt` | +| `hops` | Number of hops the packet has travelled | `eq` `neq` `gt` `lt` | +| `pathsize` | Hash size per hop entry in path (1–3 bytes) | `eq` `neq` `gt` `lt` | +| `path` | Last hop repeater hash (OR-match against a list) | `eq` `neq` | +| `channel` | Channel hash byte — OR-list (GRP_TXT and GRP_DATA only) | `eq` `neq` | +| `snr` | SNR of the received packet in whole dB | `eq` `neq` `gt` `lt` | +| `rssi` | RSSI of the received packet in dBm | `eq` `neq` `gt` `lt` | + +--- + +## Operators + +| Token | Meaning | +|---|---| +| `eq` | Equal to | +| `neq` | Not equal to | +| `gt` | Greater than | +| `lt` | Less than | + +> `gt` and `lt` are not meaningful for `path` and are silently ignored. + +--- + +## AND condition + +A rule can have an optional AND condition. Both the primary and the AND condition must match for the rule to fire. + +``` +filter add and +``` + +**Restrictions:** +- Maximum one AND condition per rule +- `path` is not supported as the AND field (it can still be the primary field) +- `channel` is not supported as the AND field (it can still be the primary field) +- The AND field must be different from the primary field + +--- + +## Values + +### `route` values + +| Token | Constant | +|---|---| +| `tflood` | `ROUTE_TYPE_TRANSPORT_FLOOD` | +| `flood` | `ROUTE_TYPE_FLOOD` | +| `direct` | `ROUTE_TYPE_DIRECT` | +| `tdirect` | `ROUTE_TYPE_TRANSPORT_DIRECT` | + +### `payload` values + +| Token | Constant | Description | +|---|---|---| +| `req` | `PAYLOAD_TYPE_REQ` | Request | +| `resp` | `PAYLOAD_TYPE_RESPONSE` | Response | +| `txt` | `PAYLOAD_TYPE_TXT_MSG` | Direct text message | +| `ack` | `PAYLOAD_TYPE_ACK` | Acknowledgement | +| `advert` | `PAYLOAD_TYPE_ADVERT` | Node advertisement | +| `grptxt` | `PAYLOAD_TYPE_GRP_TXT` | Group text message | +| `grpdata` | `PAYLOAD_TYPE_GRP_DATA` | Group data packet | +| `anonreq` | `PAYLOAD_TYPE_ANON_REQ` | Anonymous request | +| `path` | `PAYLOAD_TYPE_PATH` | Path packet | +| `trace` | `PAYLOAD_TYPE_TRACE` | Trace packet | +| `multi` | `PAYLOAD_TYPE_MULTIPART` | Multipart packet | +| `ctrl` | `PAYLOAD_TYPE_CONTROL` | Control/discovery packet | +| `raw` | `PAYLOAD_TYPE_RAW_CUSTOM` | Raw custom packet | + +Numeric values (decimal or hex) are also accepted for `payload`, e.g. `5` or `0x05`. + +### `path` values + +One or more hex strings separated by spaces. Hash size is determined automatically from the number of hex characters: + +| Hex chars | Bytes | Example | +|---|---|---| +| 2 | 1 | `AB` | +| 4 | 2 | `ABBA` | +| 6 | 3 | `ABBA11` | + +All hashes in a single rule must be the same size — mixing sizes returns an error. The size must match the hash size your network is configured to use. + +The rule matches if the last hop in the packet path equals **any** of the listed hashes (OR logic). + +``` +filter add drop path eq AB ← single 1-byte hash +filter add drop path eq AB CD EF ← any of three 1-byte hashes +filter add drop path eq ABBA CDCD ← any of two 2-byte hashes +filter add drop path eq ABBA11 CDCD22 ← any of two 3-byte hashes +``` + +> `path` cannot be used as an AND field. It can only be the primary field. + +### `channel` values + +One or more hex bytes separated by spaces, with or without `0x` prefix. The rule matches if the packet's channel hash equals **any** of the listed values (OR logic). Only applies to `grptxt` and `grpdata` packets. + +``` +filter add drop channel eq 0xAB ← single channel +filter add drop channel eq 0x81 0x11 0x22 ← any of these three channels +``` + +> `channel` cannot be used as an AND field. It can only be the primary field. + +### `snr` values + +Signed integer in whole dB, e.g. `-10` or `5`. Stored internally in quarter-dB units to match the radio driver. + +### `rssi` values + +Signed integer in dBm, e.g. `-110`. + +--- + +## Default mode + +When no rule matches, the default policy applies: + +| Token | Behaviour | +|---|---| +| `allow` | Forward the packet *(default at first boot)* | +| `drop` | Drop the packet | + +Use `filter mode drop` together with explicit `allow` rules to build a whitelist. Use `filter mode allow` (the default) with `drop` rules to build a blacklist. + +--- + +## `filter list` output format + +The number of rules per page is determined dynamically — longer rules (with AND) may result in fewer rules per page to stay within the packet size limit. If more pages exist, the last line shows the next command to run. + +``` +mode:allow rules:6/8 p1/2 +0 drop payload eq grptxt +1 drop route eq tflood and hops gt 5 +2* drop snr lt -10 +-> filter list 1 +``` + +``` +mode:allow rules:6/8 p2/2 +3 allow path eq AB 12 +4 drop channel eq 0x81 0x11 0x22 and hops gt 8 +5 drop rssi lt -110 +``` + +- The number at the start is the rule ID used with `del`, `disable`, and `enable`. +- A `*` after the ID means the rule is currently **disabled**. +- Page numbers are 0-indexed in the command, but displayed as 1-indexed in the header. + +--- + +## Examples + +### Drop all group text messages +``` +filter add drop payload eq grptxt +``` + +### Drop all transport flood packets +``` +filter add drop route eq tflood +``` + +### Drop packets that have travelled more than 5 hops +``` +filter add drop hops gt 5 +``` + +### Drop packets with poor SNR +``` +filter add drop snr lt -10 +``` + +### Drop packets with weak signal +``` +filter add drop rssi lt -110 +``` + +### Drop packets arriving via a specific repeater +``` +filter add drop path eq AB +``` + +### Drop packets arriving via any of several repeaters +``` +filter add drop path eq AB 12 CD +``` + +### Drop packets on a specific channel +``` +filter add drop channel eq 0xAB +``` + +### Drop packets on any of several channels +``` +filter add drop channel eq 0x81 0x11 0x22 +``` + +### Drop group messages on specific channels only when more than 8 hops away +``` +filter add drop channel eq 0x81 0x11 0x22 and hops gt 8 +``` + +### Drop group messages on a single channel only when more than 8 hops away +``` +filter add drop channel eq 0x11 and hops gt 8 +``` + +### Drop packets with poor SNR AND weak signal (both must be true) +``` +filter add drop snr lt -10 and rssi lt -100 +``` + +### Drop packets via a specific repeater only if they have travelled far +``` +filter add drop path eq AB and hops gt 4 +``` + +### Whitelist mode — only forward flood packets, drop everything else +``` +filter mode drop +filter add allow route eq flood +``` + +### Whitelist mode — only forward direct text messages with good signal +``` +filter mode drop +filter add allow payload eq txt and rssi gt -90 +``` + +### Disable a rule temporarily without deleting it +``` +filter disable 2 +``` + +### Re-enable it +``` +filter enable 2 +``` + +### View all rules +``` +filter list +``` + +### View second page +``` +filter list 1 +``` + +### Delete rule 1 +``` +filter del 1 +``` + +### Delete all rules and reset to default policy +``` +filter clear +filter mode allow +``` + +--- + +## Limits + +| Parameter | Value | +|---|---| +| Maximum rules | 8 | +| Maximum AND conditions per rule | 1 | +| Maximum path hashes per rule | 4 | +| Maximum channel hashes per rule | 4 | +| Path hash size | 1, 2 or 3 bytes (2, 4 or 6 hex chars) — all hashes in a rule must be the same size | +| `path` as AND field | Not supported | +| `channel` as AND field | Not supported | \ No newline at end of file diff --git a/examples/simple_repeater/FilterParser.cpp b/examples/simple_repeater/FilterParser.cpp new file mode 100644 index 0000000000..5bf50d8dc3 --- /dev/null +++ b/examples/simple_repeater/FilterParser.cpp @@ -0,0 +1,449 @@ +#include "FilterParser.h" +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal tokenizer +// --------------------------------------------------------------------------- + +// Maximum token length (no single token should exceed this) +#define MAX_TOKEN_LEN 16 + +struct Tokenizer { + const char* pos; // current position in input string +}; + +// Copy the next whitespace-delimited token into 'out' (null-terminated). +// Returns false if no more tokens are available. +static bool nextToken(Tokenizer& tz, char out[MAX_TOKEN_LEN + 1]) { + // Skip leading whitespace + while (*tz.pos == ' ' || *tz.pos == '\t') tz.pos++; + + if (*tz.pos == '\0') return false; + + int i = 0; + while (*tz.pos != '\0' && *tz.pos != ' ' && *tz.pos != '\t') { + if (i < MAX_TOKEN_LEN) { + out[i++] = (char)tolower((unsigned char)*tz.pos); + } + tz.pos++; + } + out[i] = '\0'; + return true; +} + +// Peek at next token without advancing position. +static bool peekToken(Tokenizer tz, char out[MAX_TOKEN_LEN + 1]) { + return nextToken(tz, out); // tz passed by value — copy is intentional +} + +// --------------------------------------------------------------------------- +// Token → enum helpers +// --------------------------------------------------------------------------- + +static bool parseAction(const char* tok, FilterAction& out) { + if (strcmp(tok, "drop") == 0) { out = FilterAction::DROP; return true; } + if (strcmp(tok, "allow") == 0) { out = FilterAction::ALLOW; return true; } + return false; +} + +static bool parseField(const char* tok, FilterField& out) { + if (strcmp(tok, "route") == 0) { out = FilterField::ROUTE; return true; } + if (strcmp(tok, "payload") == 0) { out = FilterField::TYPE; return true; } + if (strcmp(tok, "hops") == 0) { out = FilterField::HOPS; return true; } + if (strcmp(tok, "pathsize") == 0) { out = FilterField::PATHSIZE; return true; } + if (strcmp(tok, "path") == 0) { out = FilterField::PATH; return true; } + if (strcmp(tok, "channel") == 0) { out = FilterField::CHANNEL; return true; } + if (strcmp(tok, "snr") == 0) { out = FilterField::SNR; return true; } + if (strcmp(tok, "rssi") == 0) { out = FilterField::RSSI; return true; } + return false; +} + +static bool parseOp(const char* tok, FilterOp& out) { + if (strcmp(tok, "eq") == 0) { out = FilterOp::EQ; return true; } + if (strcmp(tok, "neq") == 0) { out = FilterOp::NEQ; return true; } + if (strcmp(tok, "gt") == 0) { out = FilterOp::GT; return true; } + if (strcmp(tok, "lt") == 0) { out = FilterOp::LT; return true; } + return false; +} + +static bool parseMode(const char* tok, FilterMode& out) { + if (strcmp(tok, "allow") == 0) { out = FilterMode::ALLOW; return true; } + if (strcmp(tok, "drop") == 0) { out = FilterMode::DROP; return true; } + return false; +} + +// --------------------------------------------------------------------------- +// Value parsers per field +// --------------------------------------------------------------------------- + +// Parse ROUTE value token → uint8_t ROUTE_TYPE_* equivalent +static bool parseRouteValue(const char* tok, int16_t& out) { + if (strcmp(tok, "tflood") == 0) { out = 0x00; return true; } // ROUTE_TYPE_TRANSPORT_FLOOD + if (strcmp(tok, "flood") == 0) { out = 0x01; return true; } // ROUTE_TYPE_FLOOD + if (strcmp(tok, "direct") == 0) { out = 0x02; return true; } // ROUTE_TYPE_DIRECT + if (strcmp(tok, "tdirect") == 0) { out = 0x03; return true; } // ROUTE_TYPE_TRANSPORT_DIRECT + return false; +} + +// Parse PAYLOAD_TYPE value token → uint8_t PAYLOAD_TYPE_* equivalent +static bool parseTypeValue(const char* tok, int16_t& out) { + if (strcmp(tok, "req") == 0) { out = 0x00; return true; } + if (strcmp(tok, "resp") == 0) { out = 0x01; return true; } + if (strcmp(tok, "txt") == 0) { out = 0x02; return true; } + if (strcmp(tok, "ack") == 0) { out = 0x03; return true; } + if (strcmp(tok, "advert") == 0) { out = 0x04; return true; } + if (strcmp(tok, "grptxt") == 0) { out = 0x05; return true; } + if (strcmp(tok, "grpdata") == 0) { out = 0x06; return true; } + if (strcmp(tok, "anonreq") == 0) { out = 0x07; return true; } + if (strcmp(tok, "path") == 0) { out = 0x08; return true; } + if (strcmp(tok, "trace") == 0) { out = 0x09; return true; } + if (strcmp(tok, "multi") == 0) { out = 0x0A; return true; } + if (strcmp(tok, "ctrl") == 0) { out = 0x0B; return true; } + if (strcmp(tok, "raw") == 0) { out = 0x0F; return true; } + // Also accept raw numeric values (decimal or hex) + char* end; + long v = strtol(tok, &end, 0); + if (*end == '\0' && v >= 0 && v <= 0x0F) { out = (int16_t)v; return true; } + return false; +} + +// Parse a hex string (with or without 0x prefix) into up to MAX_PATH_HASH_SIZE bytes. +// Returns number of bytes written, or 0 on failure. +static uint8_t parseHexBytes(const char* tok, uint8_t* out) { + // Skip optional 0x / 0X prefix — check both chars exist first + if (tok[0] == '0' && tok[1] != '\0' && (tok[1] == 'x' || tok[1] == 'X')) tok += 2; + + size_t hexlen = strlen(tok); + if (hexlen == 0 || hexlen > (MAX_PATH_HASH_SIZE * 2) || (hexlen & 1) != 0) return 0; + + for (size_t i = 0; i < hexlen; i += 2) { + char hi = tok[i]; + char lo = tok[i + 1]; + + auto hexdig = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + + int h = hexdig(hi); + int l = hexdig(lo); + if (h < 0 || l < 0) return 0; + + out[i / 2] = (uint8_t)((h << 4) | l); + } + return (uint8_t)(hexlen / 2); +} + +// Parse a scalar value token for a given field into out. +// Returns false if the token is not valid for the field. +static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { + switch (field) { + case FilterField::ROUTE: + return parseRouteValue(tok, out); + case FilterField::TYPE: + return parseTypeValue(tok, out); + case FilterField::HOPS: + case FilterField::PATHSIZE: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::CHANNEL: + // Channel OR-list is handled separately in parseAddCommand. + // parseScalarValue is only called for AND conditions — channel not allowed there. + return false; + case FilterField::SNR: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -128 || v > 127) return false; + out = (int16_t)(v * 4); // store as quarter-dB + return true; + } + case FilterField::RSSI: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -32768 || v > 32767) return false; + out = (int16_t)v; + return true; + } + default: + return false; + } +} + +static FilterParseResult parseAddCommand(Tokenizer& tz) { + FilterParseResult result; + result.error = FilterParseError::OK; + result.command = FilterCommand::ADD; + memset(&result.rule, 0, sizeof(FilterRule)); + result.rule.enabled = false; // rules are added disabled — use 'filter enable ' to activate + result.rule.in_use = true; + result.rule.and_field = FILTER_FIELD_NONE; // no AND condition by default + + char tok[MAX_TOKEN_LEN + 1]; + + // --- action --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseAction(tok, result.rule.action)) { result.error = FilterParseError::UNKNOWN_ACTION; return result; } + + // --- field --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseField(tok, result.rule.field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // --- operator --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, result.rule.op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // --- value (field-specific) --- + if (result.rule.field == FilterField::PATH) { + // PATH: one or more hex hash tokens (OR-list), stops at "and" or end of input + uint8_t count = 0; + uint8_t hashlen = 0; + + while (peekToken(tz, tok)) { + if (strcmp(tok, "and") == 0) break; + nextToken(tz, tok); + + if (count >= MAX_PATH_HASHES_PER_RULE) { + result.error = FilterParseError::TOO_MANY_HASHES; + return result; + } + + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 0) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + + if (hashlen == 0) { + hashlen = len; + } else if (len != hashlen) { + result.error = FilterParseError::HASH_SIZE_MISMATCH; + return result; + } + + memcpy(result.rule.path_hashes[count], bytes, len); + count++; + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.path_hash_len = hashlen; + result.rule.path_hash_count = count; + + } else if (result.rule.field == FilterField::CHANNEL) { + // CHANNEL: one or more space-separated hex byte tokens (OR-list) + // AND condition not supported when channel is primary field + uint8_t count = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); + + if (count >= MAX_CHANNEL_HASHES) { + result.error = FilterParseError::TOO_MANY_CHANNELS; + return result; + } + + // Accept 0xNN or NN (exactly 1 byte) + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { + result.rule.channel_hashes[count++] = bytes[0]; + } else { + // Try plain decimal + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + result.rule.channel_hashes[count++] = (uint8_t)v; + } + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.channel_hash_count = count; + // Channel OR-list parsed — fall through to AND condition parsing below + + } else { + // Scalar field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, result.rule.field, result.rule.value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + } + + // --- optional AND condition --- + if (peekToken(tz, tok) && strcmp(tok, "and") == 0) { + nextToken(tz, tok); // consume "and" + + // AND field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + FilterField and_field; + if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_FIELD; + return result; + } + + // PATH not supported as AND condition + if (and_field == FilterField::PATH) { + result.error = FilterParseError::AND_PATH_NOT_ALLOWED; + return result; + } + + // CHANNEL not supported as AND condition + if (and_field == FilterField::CHANNEL) { + result.error = FilterParseError::AND_CHANNEL_NOT_ALLOWED; + return result; + } + + // AND operator + FilterOp and_op; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, and_op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // AND value + int16_t and_value = 0; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, and_field, and_value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + + result.rule.and_field = (uint8_t)and_field; + result.rule.and_op = and_op; + result.rule.and_value = and_value; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +FilterParseResult parseFilterCommand(const char* input) { + FilterParseResult result; + memset(&result, 0, sizeof(result)); // zero all fields including rule — safe default for all error paths + + Tokenizer tz = { input }; + char tok[MAX_TOKEN_LEN + 1]; + + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + // --- route to sub-command --- + if (strcmp(tok, "add") == 0) { + return parseAddCommand(tz); + } + + if (strcmp(tok, "list") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::LIST; + result.rule_id = 0; // default page 0 + // Optional page number: "filter list 1" + if (peekToken(tz, tok)) { + nextToken(tz, tok); + char* end; + long page = strtol(tok, &end, 10); + if (*end != '\0' || page < 0) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.rule_id = (uint8_t)page; + } + return result; + } + + if (strcmp(tok, "clear") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::CLEAR; + return result; + } + + if (strcmp(tok, "del") == 0 || strcmp(tok, "disable") == 0 || strcmp(tok, "enable") == 0) { + FilterCommand cmd = (strcmp(tok, "del") == 0) ? FilterCommand::DEL : + (strcmp(tok, "disable") == 0) ? FilterCommand::DISABLE : + FilterCommand::ENABLE; + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + char* end; + long id = strtol(tok, &end, 10); + if (*end != '\0' || id < 0 || id >= MAX_FILTER_RULES) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.error = FilterParseError::OK; + result.command = cmd; + result.rule_id = (uint8_t)id; + return result; + } + + if (strcmp(tok, "mode") == 0) { + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + if (!parseMode(tok, result.mode)) { + result.error = FilterParseError::UNKNOWN_MODE; + return result; + } + result.error = FilterParseError::OK; + result.command = FilterCommand::MODE; + return result; + } + + result.error = FilterParseError::UNKNOWN_COMMAND; + return result; +} + +// --------------------------------------------------------------------------- +// Error string helper +// --------------------------------------------------------------------------- + +const char* filterParseErrorStr(FilterParseError err) { + switch (err) { + case FilterParseError::OK: return "OK"; + case FilterParseError::UNKNOWN_COMMAND: return "Err - unknown command"; + case FilterParseError::UNKNOWN_ACTION: return "Err - unknown action (use: drop, allow)"; + case FilterParseError::UNKNOWN_FIELD: return "Err - unknown field (use: route, payload, hops, pathsize, path, channel, snr, rssi)"; + case FilterParseError::UNKNOWN_OP: return "Err - unknown operator (use: eq, neq, gt, lt)"; + case FilterParseError::UNKNOWN_VALUE: return "Err - unknown or out-of-range value"; + case FilterParseError::MISSING_TOKEN: return "Err - missing token"; + case FilterParseError::INVALID_RULE_ID: return "Err - invalid rule id"; + case FilterParseError::INVALID_HEX: return "Err - invalid hex value"; + case FilterParseError::TOO_MANY_HASHES: return "Err - too many path hashes (max 4)"; + case FilterParseError::HASH_SIZE_MISMATCH:return "Err - mixed hash sizes in path rule"; + case FilterParseError::UNKNOWN_MODE: return "Err - unknown mode (use: allow, drop)"; + case FilterParseError::AND_PATH_NOT_ALLOWED: return "Err - path field not supported as AND condition"; + case FilterParseError::AND_DUPLICATE_FIELD: return "Err - AND condition cannot use same field as primary"; + case FilterParseError::AND_CHANNEL_NOT_ALLOWED: return "Err - channel field not supported as AND condition"; + case FilterParseError::TOO_MANY_CHANNELS: return "Err - too many channel hashes (max 4)"; + default: return "Err - unknown error"; + } +} \ No newline at end of file diff --git a/examples/simple_repeater/FilterParser.h b/examples/simple_repeater/FilterParser.h new file mode 100644 index 0000000000..618e78bdb4 --- /dev/null +++ b/examples/simple_repeater/FilterParser.h @@ -0,0 +1,64 @@ +#pragma once + +#include "FilterRule.h" + +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +enum class FilterParseError : uint8_t { + OK = 0, + UNKNOWN_COMMAND, // unrecognised sub-command after "filter" + UNKNOWN_ACTION, // unrecognised action token (expected drop/allow) + UNKNOWN_FIELD, // unrecognised field token + UNKNOWN_OP, // unrecognised operator token + UNKNOWN_VALUE, // unrecognised or out-of-range value token + MISSING_TOKEN, // expected another token but input ended + INVALID_RULE_ID, // rule id out of range or not a number + INVALID_HEX, // malformed hex string + TOO_MANY_HASHES, // more path hashes than MAX_PATH_HASHES_PER_RULE + HASH_SIZE_MISMATCH, // mixed hash sizes in a single path rule + UNKNOWN_MODE, // unrecognised mode token (expected allow/drop) + AND_PATH_NOT_ALLOWED, // path field not supported as AND condition + AND_DUPLICATE_FIELD, // AND condition uses same field as primary condition + AND_CHANNEL_NOT_ALLOWED, // channel field not supported as AND condition + TOO_MANY_CHANNELS, // more channel hashes than MAX_CHANNEL_HASHES +}; + +// --------------------------------------------------------------------------- +// Command types returned by the parser +// --------------------------------------------------------------------------- + +enum class FilterCommand : uint8_t { + ADD, // add a new rule — result.rule is populated + DEL, // delete by id — result.rule_id is populated + LIST, + DISABLE, // disable by id — result.rule_id is populated + ENABLE, // enable by id — result.rule_id is populated + CLEAR, + MODE, // set default policy — result.mode is populated +}; + +// --------------------------------------------------------------------------- +// Parse result +// --------------------------------------------------------------------------- + +struct FilterParseResult { + FilterParseError error; + FilterCommand command; + FilterRule rule; // valid when command == ADD and error == OK + uint8_t rule_id; // valid when command == DEL / DISABLE / ENABLE + FilterMode mode; // valid when command == MODE +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Parse a full "filter ..." command string. +// 'input' must be a null-terminated C string starting after "filter ". +// The returned FilterParseResult is valid for the lifetime of the call. +FilterParseResult parseFilterCommand(const char *input); + +// Return a short human-readable description of a parse error. +const char *filterParseErrorStr(FilterParseError err); \ No newline at end of file diff --git a/examples/simple_repeater/FilterRule.h b/examples/simple_repeater/FilterRule.h new file mode 100644 index 0000000000..c3aaf38e2b --- /dev/null +++ b/examples/simple_repeater/FilterRule.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +#define MAX_FILTER_RULES 8 +#define MAX_PATH_HASHES_PER_RULE 4 +#define MAX_PATH_HASH_SIZE 3 // 1, 2 or 3 bytes per hash +#define MAX_CHANNEL_HASHES 4 // OR-list for channel field + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +enum class FilterAction : uint8_t { + DROP = 0, + ALLOW = 1, +}; + +enum class FilterField : uint8_t { + ROUTE = 0, // getRouteType() — ROUTE_TYPE_* values + TYPE = 1, // getPayloadType() — PAYLOAD_TYPE_* values + HOPS = 2, // getPathHashCount() — number of hops + PATHSIZE = 3, // getPathHashSize() — bytes per path hash (1-3) + PATH = 4, // last hop in path — OR-match against hash list + CHANNEL = 5, // payload[0] — channel hash (GRP_TXT / GRP_DATA) + SNR = 6, // packet->_snr — stored in quarter-dB, compared in whole dB + RSSI = 7, // passed in at eval — dBm +}; + +enum class FilterOp : uint8_t { + EQ = 0, // equal + NEQ = 1, // not equal + GT = 2, // greater than + LT = 3, // less than +}; + +// --------------------------------------------------------------------------- +// Default policy when no rule matches +// --------------------------------------------------------------------------- + +enum class FilterMode : uint8_t { + ALLOW = 0, // default-allow (blacklist mode) + DROP = 1, // default-drop (whitelist mode) +}; + +// --------------------------------------------------------------------------- +// Rule struct +// --------------------------------------------------------------------------- + +// Sentinel value meaning "no AND condition" +#define FILTER_FIELD_NONE 0xFF + +struct FilterRule { + FilterAction action; + FilterField field; + FilterOp op; + + // Scalar comparison value. + // SNR : stored in quarter-dB (matches packet->_snr units), parser converts from whole dB + // RSSI : stored in dBm (int16_t) + // All other fields: uint8_t cast to int16_t + int16_t value; + + // Optional AND condition — active only when and_field != FILTER_FIELD_NONE. + // PATH field is not supported as an AND condition. + uint8_t and_field; // raw uint8_t so FILTER_FIELD_NONE (0xFF) fits without casting + FilterOp and_op; + int16_t and_value; + + // PATH field only: list of hashes for OR-match against the last hop in path. + // Unused slots are zero-filled. + uint8_t path_hashes[MAX_PATH_HASHES_PER_RULE][MAX_PATH_HASH_SIZE]; + uint8_t path_hash_len; // bytes per hash (1, 2 or 3) — same for all hashes in this rule + uint8_t path_hash_count; // number of valid hashes in path_hashes (1..MAX_PATH_HASHES_PER_RULE) + + // CHANNEL field only: OR-list of channel hash bytes. + // Unused slots are zero-filled. + // AND condition is not supported when field == CHANNEL. + uint8_t channel_hashes[MAX_CHANNEL_HASHES]; + uint8_t channel_hash_count; // number of valid entries (1..MAX_CHANNEL_HASHES) + + bool enabled; // false = rule is defined but temporarily inactive + bool in_use; // false = slot is empty +}; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1b0ca1916c..5af26a0922 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -344,7 +344,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t int results_offset = 0; uint8_t results_buffer[130]; for(int index = 0; index < count && index + offset < neighbours_count; index++){ - + // stop if we can't fit another entry in results int entry_size = pubkey_prefix_length + 4 + 1; if(results_offset + entry_size > sizeof(results_buffer)){ @@ -562,6 +562,11 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } + // Run packet through filter engine (channel, path, route, type, SNR, RSSI, etc.) + if (_filter.evaluate(pkt, (int16_t)_radio->getLastRSSI())) { + MESH_DEBUG_PRINTLN("*** FILTER: packet dropped by rule ***"); + return true; + } // do normal processing return false; } @@ -935,6 +940,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 { @@ -1152,7 +1158,7 @@ void MyMesh::formatRadioStatsReply(char *reply) { } void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } @@ -1390,7 +1396,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } - } else{ + } else if (strncmp(command, "filter", 6) == 0 && (command[6] == ' ' || command[6] == '\0')) { + const char* filter_args = (command[6] == ' ') ? command + 7 : ""; + _filter.handleCommand(filter_args, reply, *_fs, sender); + } 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 fbc756f471..19cc32b25e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include "RateLimiter.h" +#include "ChannelFilter.h" #ifdef WITH_BRIDGE extern AbstractBridge* bridge; @@ -103,6 +104,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + ChannelFilter _filter; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif