mirror of
https://github.com/meshtastic/firmware.git
synced 2026-08-02 03:19:26 -04:00
Merge branch 'develop' into indicator-comms
This commit is contained in:
@@ -4,19 +4,16 @@
|
||||
# As this is not a long running service, health-checks are not required. ClusterFuzzLite
|
||||
# also only works if the user remains unchanged from the base image (it expects to run
|
||||
# as root).
|
||||
# trunk-ignore-all(trivy/DS026): No healthcheck is needed for this builder container
|
||||
# trunk-ignore-all(trivy/DS-0026): No healthcheck is needed for this builder container
|
||||
# trunk-ignore-all(checkov/CKV_DOCKER_2): No healthcheck is needed for this builder container
|
||||
# trunk-ignore-all(checkov/CKV_DOCKER_3): We must run as root for this container
|
||||
# trunk-ignore-all(trivy/DS002): We must run as root for this container
|
||||
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
|
||||
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container
|
||||
# trunk-ignore-all(trivy/DS-0002): We must run as root for this container
|
||||
|
||||
FROM gcr.io/oss-fuzz-base/base-builder:v1
|
||||
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# trunk-ignore(hadolint/DL3008): apt packages are not pinned.
|
||||
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
|
||||
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
|
||||
|
||||
@@ -13,7 +13,7 @@ reviews:
|
||||
drafts: false
|
||||
# Review PRs regardless of target branch.
|
||||
base_branches:
|
||||
- ".*"
|
||||
- .*
|
||||
# Skip Renovate dependency updates - CI gates dependencies; we review for substance, not every bump.
|
||||
ignore_usernames:
|
||||
- renovate
|
||||
@@ -27,7 +27,7 @@ reviews:
|
||||
- "!protobufs/**"
|
||||
|
||||
path_instructions:
|
||||
- path: "bin/config.d/**"
|
||||
- path: bin/config.d/**
|
||||
instructions: >
|
||||
meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging.
|
||||
Ensure configurations include metadata found in other configs.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# trunk-ignore-all(terrascan/AC_DOCKER_0002): Known terrascan issue
|
||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
FROM mcr.microsoft.com/devcontainers/cpp:2-debian-13
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"installTools": true,
|
||||
"version": "3.13"
|
||||
"version": "3.14"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -677,6 +677,8 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_rtc/` - RTC / time handling
|
||||
- `test_serial/` - Serial communication
|
||||
- `test_tak_config/` - TAK (ATAK) team/role value fidelity through set/save/load/get
|
||||
- `test_module_config/` - every ModuleConfig submessage survives admin set -> save -> load -> get
|
||||
- `test_traffic_management/` - Traffic management (dedup, rate-limit, hop-trim, role exceptions)
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
|
||||
|
||||
53
.github/workflows/build_macos_bin.yml
vendored
53
.github/workflows/build_macos_bin.yml
vendored
@@ -14,30 +14,71 @@ permissions:
|
||||
jobs:
|
||||
build-MacOS:
|
||||
runs-on: macos-${{ inputs.macos_ver }}
|
||||
# Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
|
||||
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
||||
env:
|
||||
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install deps
|
||||
- name: Capture runner image version (for caching)
|
||||
id: r_image
|
||||
run: echo "ver=$ImageVersion" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Homebrew cache
|
||||
id: brew-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/Library/Caches/Homebrew
|
||||
key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }}
|
||||
|
||||
- name: Install deps (Homebrew)
|
||||
shell: bash
|
||||
run: |
|
||||
brew update
|
||||
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
|
||||
env:
|
||||
# GitHub-Hosted MacOS runner images are updated frequently
|
||||
# so skip running `brew update` to avoid unnecessary failures and delays.
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: "1"
|
||||
run: >
|
||||
brew install
|
||||
platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius jsoncpp
|
||||
|
||||
- name: Save Homebrew cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.brew-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/Library/Caches/Homebrew
|
||||
key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }}
|
||||
|
||||
- name: Get release version string
|
||||
run: |
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
id: version
|
||||
|
||||
- name: Restore PlatformIO cache
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
restore-keys: |
|
||||
pio-macos-${{ inputs.macos_ver }}-
|
||||
|
||||
- name: Build for MacOS
|
||||
run: |
|
||||
platformio run -e native-macos
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.long }}
|
||||
# Errors in this step should not fail the entire workflow while MacOS support is in development.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: List output files
|
||||
run: ls -lah .pio/build/native-macos/
|
||||
|
||||
22
.github/workflows/build_portduino_wasm.yml
vendored
22
.github/workflows/build_portduino_wasm.yml
vendored
@@ -18,6 +18,10 @@ jobs:
|
||||
build-wasm:
|
||||
name: Build PortDuino WASM
|
||||
runs-on: ubuntu-latest
|
||||
# Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
|
||||
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
||||
env:
|
||||
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -29,14 +33,30 @@ jobs:
|
||||
uses: ./.github/actions/setup-native
|
||||
|
||||
- name: Setup Emscripten SDK
|
||||
uses: emscripten-core/setup-emsdk@v16
|
||||
uses: emscripten-core/setup-emsdk@0822153d7a5488b70a269cfa0a631b2a86ab4da2 # 'v17' main
|
||||
with:
|
||||
version: 6.0.1
|
||||
actions-cache-folder: emsdk-cache
|
||||
|
||||
- name: Restore PlatformIO cache
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
restore-keys: |
|
||||
pio-wasm-
|
||||
|
||||
- name: Build PortDuino WASM
|
||||
run: platformio run -e native-wasm
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: Assert WASM artifacts
|
||||
run: |
|
||||
OUT=.pio/build/native-wasm
|
||||
|
||||
24
.github/workflows/build_windows_bin.yml
vendored
24
.github/workflows/build_windows_bin.yml
vendored
@@ -14,6 +14,10 @@ permissions:
|
||||
jobs:
|
||||
build-Windows:
|
||||
runs-on: windows-${{ inputs.windows_ver }}
|
||||
# Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
|
||||
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
||||
env:
|
||||
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
||||
defaults:
|
||||
run:
|
||||
# UCRT64 is the MinGW-w64 environment native-windows targets; the `msys`
|
||||
@@ -33,6 +37,7 @@ jobs:
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
cache: true
|
||||
# argp is not packaged for the mingw environments and is built below.
|
||||
# Python is omitted too: MSYS2's reports a `mingw` platform tag no wheel matches.
|
||||
install: >-
|
||||
@@ -50,7 +55,8 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
|
||||
# framework-portduino calls argp_parse(); MSYS2 packages argp only for the
|
||||
# msys runtime, which cannot link into a native binary. No install() rules.
|
||||
@@ -69,6 +75,15 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install platformio
|
||||
|
||||
- name: Restore PlatformIO cache
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
restore-keys: |
|
||||
pio-windows-${{ inputs.windows_ver }}-
|
||||
|
||||
- name: Get release version string
|
||||
shell: pwsh
|
||||
id: version
|
||||
@@ -84,6 +99,13 @@ jobs:
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.long }}
|
||||
|
||||
- name: Save PlatformIO cache
|
||||
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.platformio/.cache
|
||||
key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
||||
|
||||
- name: List output files
|
||||
run: ls -lah .pio/build/native-windows/
|
||||
|
||||
|
||||
13
.github/workflows/docker_build.yml
vendored
13
.github/workflows/docker_build.yml
vendored
@@ -45,6 +45,10 @@ jobs:
|
||||
outputs:
|
||||
digest: ${{ steps.docker_variant.outputs.digest }}
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
env:
|
||||
# Only cache on default branch (develop).
|
||||
# 'docker/' actions don't support separate Restore/Save actions, so we have to disable caching entirely on PRs/merge_queue.
|
||||
USE_CACHE: ${{ github.ref_name == github.event.repository.default_branch }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
@@ -59,11 +63,12 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: true
|
||||
cache-image: ${{ env.USE_CACHE }}
|
||||
|
||||
- name: Docker setup
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
cache-binary: ${{ env.USE_CACHE }}
|
||||
# Add Google/Amazon DockerHub mirrors to buildkit config
|
||||
# https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror
|
||||
buildkitd-config-inline: |
|
||||
@@ -105,6 +110,6 @@ jobs:
|
||||
platforms: ${{ inputs.platform }}
|
||||
build-args: |
|
||||
PIO_ENV=${{ inputs.pio_env }}
|
||||
# Cache image layers in GitHub Actions cache to speed up subsequent builds.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds.
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
|
||||
38
.github/workflows/main_matrix.yml
vendored
38
.github/workflows/main_matrix.yml
vendored
@@ -3,12 +3,21 @@ concurrency:
|
||||
group: ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
on:
|
||||
# # Triggers the workflow on push but only for the main branches
|
||||
# The merge queue is the pre-merge gate for the protected branches (master,
|
||||
# develop): a merge_group run validates the merged result before code lands. For
|
||||
# now PRs and merge_group runs build the same narrowed board subset (--level pr);
|
||||
# see the setup job. push still runs the full matrix on master/develop (post-merge)
|
||||
# as well as on the event/* and feature/* branches, which have no merge queue.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- pioarduino # Remove when merged // use `feature/` in the future.
|
||||
- event/*
|
||||
- feature/*
|
||||
paths-ignore:
|
||||
@@ -19,7 +28,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- pioarduino # Remove when merged // use `feature/` in the future.
|
||||
- event/*
|
||||
- feature/*
|
||||
paths-ignore:
|
||||
@@ -28,9 +36,9 @@ on:
|
||||
|
||||
schedule:
|
||||
# Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release).
|
||||
# Scheduled runs execute on the default branch (develop). 09:00 UTC avoids the 00:00 tests
|
||||
# Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests
|
||||
# and 02:00 daily_packaging crons.
|
||||
- cron: 0 9 * * * # Nightly develop build/publish (default branch is develop)
|
||||
- cron: 0 7 * * * # Nightly develop build/publish (default branch is develop)
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -61,10 +69,12 @@ jobs:
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
run: |
|
||||
if [[ "$GITHUB_HEAD_REF" == "" ]]; then
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
|
||||
else
|
||||
# PRs and (for now) merge_group builds use the narrowed --level pr board
|
||||
# subset. Full-matrix builds run on push / schedule / workflow_dispatch.
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr)
|
||||
else
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}})
|
||||
fi
|
||||
echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF"
|
||||
echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT
|
||||
@@ -189,15 +199,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distro: [debian, alpine]
|
||||
platform: [linux/amd64, linux/arm64, linux/arm/v7]
|
||||
pio_env: [native, native-tft]
|
||||
exclude:
|
||||
- distro: alpine
|
||||
platform: linux/arm/v7
|
||||
- pio_env: native-tft
|
||||
platform: linux/arm64
|
||||
- pio_env: native-tft
|
||||
platform: linux/arm/v7
|
||||
platform: [linux/arm64]
|
||||
pio_env: [native-tft]
|
||||
uses: ./.github/workflows/docker_build.yml
|
||||
with:
|
||||
distro: ${{ matrix.distro }}
|
||||
@@ -207,7 +210,6 @@ jobs:
|
||||
push: false
|
||||
|
||||
gather-artifacts:
|
||||
# trunk-ignore(checkov/CKV2_GHA_1)
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -36,4 +36,4 @@ jobs:
|
||||
- name: Trunk Upgrade
|
||||
uses: trunk-io/trunk-action/upgrade@v1
|
||||
with:
|
||||
base: master
|
||||
base: develop
|
||||
|
||||
9
.github/workflows/pr_tests.yml
vendored
9
.github/workflows/pr_tests.yml
vendored
@@ -4,10 +4,11 @@ name: Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7): the reason input is a free-text run label, never fed into the build
|
||||
reason:
|
||||
description: "Reason for manual test run"
|
||||
description: Reason for manual test run
|
||||
required: false
|
||||
default: "Manual test execution"
|
||||
default: Manual test execution
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.head_ref || github.run_id }}
|
||||
@@ -21,7 +22,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
native-tests:
|
||||
name: "🧪 Native Tests"
|
||||
name: 🧪 Native Tests
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
uses: ./.github/workflows/test_native.yml
|
||||
permissions:
|
||||
@@ -30,7 +31,7 @@ jobs:
|
||||
checks: write
|
||||
|
||||
test-summary:
|
||||
name: "📊 Test Results"
|
||||
name: 📊 Test Results
|
||||
runs-on: ubuntu-latest
|
||||
needs: [native-tests]
|
||||
if: always()
|
||||
|
||||
159
.github/workflows/test_native.yml
vendored
159
.github/workflows/test_native.yml
vendored
@@ -10,11 +10,56 @@ env:
|
||||
LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}"
|
||||
|
||||
jobs:
|
||||
# Guard the registered native-suite total. `platformio test` discovers and runs whatever
|
||||
# test_* directories exist, so it never notices when test/native-suite-count drifts from the
|
||||
# actual directory count (a suite added without registering it, or the file left stale). That
|
||||
# reconciliation only lives in bin/run-tests.sh, which CI does not invoke - so mirror the exact
|
||||
# check here and fail the PR on a mismatch, keeping the manual count honest.
|
||||
suite-count-check:
|
||||
name: Native Suite Count
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reconcile native-suite-count with test/ directories
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
count_file="test/native-suite-count"
|
||||
if [[ ! -f $count_file ]]; then
|
||||
echo "::error title=Missing native-suite-count::$count_file not found - it must record the number of test_* suite directories."
|
||||
exit 1
|
||||
fi
|
||||
# Same canonical set as bin/run-tests.sh: directories named test_* directly under test/.
|
||||
expected_count=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | wc -l)
|
||||
canonical_count=$(tr -d '[:space:]' <"$count_file")
|
||||
if ! [[ $canonical_count =~ ^[0-9]+$ ]]; then
|
||||
echo "::error title=Invalid native-suite-count::$count_file must contain a single integer, got '$canonical_count'."
|
||||
exit 1
|
||||
fi
|
||||
echo "test/ directories: $expected_count"
|
||||
echo "native-suite-count: $canonical_count"
|
||||
if [[ $expected_count -ne $canonical_count ]]; then
|
||||
if [[ $expected_count -gt $canonical_count ]]; then
|
||||
hint="a suite was added - bump $count_file to $expected_count"
|
||||
else
|
||||
hint="a suite was removed - lower $count_file to $expected_count"
|
||||
fi
|
||||
echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)."
|
||||
exit 1
|
||||
fi
|
||||
echo "native-suite-count matches the $expected_count suite directories."
|
||||
|
||||
simulator-tests:
|
||||
name: Native Simulator Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: suite-count-check
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -87,8 +132,9 @@ jobs:
|
||||
platformio-tests:
|
||||
name: Native PlatformIO Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: suite-count-check
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -105,14 +151,90 @@ jobs:
|
||||
- name: Disable BUILD_EPOCH
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
- name: Build test programs once
|
||||
# One shared build of src + every test program. This is the single source build; gcov then
|
||||
# accumulates coverage counts into this shared .pio/build/coverage/src as the chunks run.
|
||||
run: platformio test -e coverage --without-testing
|
||||
|
||||
- name: Run tests one area at a time
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Filter out SKIPPED summary rows for hardware variants that can't run on the
|
||||
# native host. They flood the log and make it harder to spot real failures.
|
||||
# The JUnit XML is written directly to testreport.xml before the pipe, so
|
||||
# the test artifact is unaffected.
|
||||
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
set -uo pipefail
|
||||
# One runner, no matrix, no concurrency. Group the test_* suites by area and run each
|
||||
# area sequentially, reusing the single build above (--without-building). Each area gets
|
||||
# its own JUnit report and its own collapsible log, so a failure lands in a small named
|
||||
# section instead of being buried past the log limit. Sequential runs share one build
|
||||
# dir, so gcov coverage accumulates and the single capture in the next step has the union.
|
||||
|
||||
# Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so
|
||||
# a newly added suite always runs even before it is placed. Add a suite to an area by
|
||||
# extending that area's regex; add a new area by inserting a rule line.
|
||||
area_rules=(
|
||||
"admin:^test_(admin|pki)_"
|
||||
"crypto:^test_(crypto|packet_signing)$"
|
||||
"routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"
|
||||
"position:^test_position_"
|
||||
"fuzz:^test_fuzz_"
|
||||
"packets:^test_(packet|transmit|meshpacket)_"
|
||||
"io:^test_(serial|stream|xmodem|http|mqtt)"
|
||||
)
|
||||
|
||||
mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
declare -A group
|
||||
for s in "${suites[@]}"; do
|
||||
a="misc"
|
||||
for rule in "${area_rules[@]}"; do
|
||||
if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi
|
||||
done
|
||||
group[$a]="${group[$a]:-} -f $s"
|
||||
done
|
||||
|
||||
run_order=()
|
||||
for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done
|
||||
run_order+=("misc")
|
||||
|
||||
fail=0
|
||||
for a in "${run_order[@]}"; do
|
||||
[ -n "${group[$a]:-}" ] || continue
|
||||
echo "::group::area $a (${group[$a]# })"
|
||||
# Capture platformio's real exit status (not grep's) via a log file, then show the log
|
||||
# with the noisy per-variant SKIPPED rows filtered out.
|
||||
if ! platformio test -e coverage --without-building -v ${group[$a]# } \
|
||||
--junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then
|
||||
fail=1
|
||||
echo "::error::area $a had test failures"
|
||||
fi
|
||||
grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Merge per-area reports into testreport.xml
|
||||
# Preserve the single-file JUnit contract that downstream consumers rely on
|
||||
# (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml).
|
||||
# The per-area split is only for readable logs; the report stays consolidated.
|
||||
if: always() # run even when a chunk failed, so the report captures the failures
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import glob, xml.etree.ElementTree as ET
|
||||
out = ET.Element('testsuites')
|
||||
for f in sorted(glob.glob('testreport-*.xml')):
|
||||
try:
|
||||
root = ET.parse(f).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
# PlatformIO writes a <testsuites> root; fold in a bare <testsuite> too, just in case.
|
||||
out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root])
|
||||
ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True)
|
||||
PY
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
run: |
|
||||
sudo apt-get install -y lcov
|
||||
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info
|
||||
sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative.
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -122,13 +244,6 @@ jobs:
|
||||
overwrite: true
|
||||
path: ./testreport.xml
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
run: |
|
||||
sudo apt-get install -y lcov
|
||||
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info
|
||||
sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative.
|
||||
|
||||
- name: Save coverage information
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -149,7 +264,7 @@ jobs:
|
||||
- platformio-tests
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Get release version string
|
||||
run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
@@ -184,9 +299,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate Code Coverage Report
|
||||
# Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline),
|
||||
# coverage_integration.info, and one coverage_tests_<chunk>.info per chunk. lcov
|
||||
# sums hit counts across them, so the merged report is the union of all chunks -
|
||||
# identical to running the whole suite in one job.
|
||||
run: |
|
||||
sudo apt-get install -y lcov
|
||||
lcov --quiet --add-tracefile code-coverage-report/coverage_base.info --add-tracefile code-coverage-report/coverage_integration.info --add-tracefile code-coverage-report/coverage_tests.info --output-file code-coverage-report/coverage_src.info
|
||||
args=()
|
||||
for f in code-coverage-report/coverage_*.info; do
|
||||
args+=(--add-tracefile "$f")
|
||||
done
|
||||
lcov --quiet "${args[@]}" --output-file code-coverage-report/coverage_src.info
|
||||
genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report
|
||||
|
||||
- name: Save Code Coverage Report
|
||||
|
||||
1
.github/workflows/trunk_annotate_pr.yml
vendored
1
.github/workflows/trunk_annotate_pr.yml
vendored
@@ -24,3 +24,4 @@ jobs:
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
post-annotations: true
|
||||
cache: false
|
||||
|
||||
1
.github/workflows/trunk_check.yml
vendored
1
.github/workflows/trunk_check.yml
vendored
@@ -22,3 +22,4 @@ jobs:
|
||||
uses: trunk-io/trunk-action@v1
|
||||
with:
|
||||
save-annotations: true
|
||||
cache: false
|
||||
|
||||
@@ -102,6 +102,13 @@ lint:
|
||||
- linters: [gitleaks]
|
||||
paths:
|
||||
- test/fixtures/nodedb/seed_v25_*.jsonl
|
||||
# checkov/CKV_SECRET_6 flags the commented-out "large4cats" example,
|
||||
# which is the public default credential for the meshtastic.org MQTT
|
||||
# broker rather than a real secret. Ignore the whole file so the
|
||||
# example config stays free of lint-suppression comments.
|
||||
- linters: [ALL]
|
||||
paths:
|
||||
- userPrefs.jsonc
|
||||
# The UA font's em/en dashes live only in trailing comments documenting
|
||||
# each glyph's codepoint (e.g. "8212=:2549"); rewriting them would make
|
||||
# those docs inaccurate. Glyph byte data is unaffected either way.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# trunk-ignore-all(trivy/DS002): We must run as root for this container
|
||||
# trunk-ignore-all(trivy/DS-0002): We must run as root for this container
|
||||
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
|
||||
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container
|
||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# trunk-ignore-all(trivy/DS002): We must run as root for this container
|
||||
# trunk-ignore-all(trivy/DS-0002): We must run as root for this container
|
||||
# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container
|
||||
# trunk-ignore-all(hadolint/DL3002): We must run as root for this container
|
||||
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
@@ -12,7 +12,8 @@ rm -f $OUTDIR/firmware*
|
||||
rm -r $OUTDIR/* || true
|
||||
|
||||
# Important to pull latest version of libs into all device flavors, otherwise some devices might be stale
|
||||
platformio pkg install -e $1
|
||||
# platformio pkg install -e $1
|
||||
# ...redundant with pioarduino
|
||||
|
||||
echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS"
|
||||
rm -f $BUILDDIR/firmware*
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Note: This is a prototype for how we could add static code analysis to the CI.
|
||||
# Run cppcheck static analysis over one or more PlatformIO environments.
|
||||
#
|
||||
# Why this is more than a bare `pio check`: the CI `check` matrix job in main_matrix.yml runs this
|
||||
# script inside meshtastic/gh-action-firmware, and `pio check` must download the platform package
|
||||
# before it can analyse anything. That download is regularly reset mid-flight by the CDN, e.g.
|
||||
#
|
||||
# Checking station-g3 > cppcheck (board: station-g3; platform: .../platform-espressif32.zip)
|
||||
# requests.exceptions.ConnectionError: ('Connection aborted.', ConnectionResetError(104, ...))
|
||||
#
|
||||
# The analysis never starts, so there is no signal at all - just a red X someone has to re-run by
|
||||
# hand. `pio check` exits 1 for that AND for real defects, so the exit code cannot tell them apart;
|
||||
# only the output can. This script classifies the failure:
|
||||
#
|
||||
# * cppcheck produced results (summary table or defect lines) -> real, propagate the exit status.
|
||||
# * transport-level network error and nothing else -> warn and skip.
|
||||
# * anything else -> propagate the exit status.
|
||||
#
|
||||
# A missing or forbidden package (404 / UnknownPackageError / 403) is deliberately NOT treated as
|
||||
# transient: that is a reproducible break from a bad pin in a .ini, not weather.
|
||||
#
|
||||
# Exit codes: 0 = clean (or skipped under CI), 1 = defects/error, 2 = skipped locally.
|
||||
|
||||
set -e
|
||||
set -uo pipefail
|
||||
|
||||
VERSION=$(bin/buildinfo.py long)
|
||||
VERSION=$(bin/buildinfo.py long) || exit 1
|
||||
|
||||
# The shell vars the build tool expects to find
|
||||
export APP_VERSION=$VERSION
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
# can override which environment by passing arg
|
||||
BOARDS="$@"
|
||||
BOARDS=("$@")
|
||||
else
|
||||
BOARDS="tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink"
|
||||
BOARDS=(tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink)
|
||||
fi
|
||||
|
||||
echo "BOARDS:${BOARDS}"
|
||||
echo "BOARDS:${BOARDS[*]}"
|
||||
|
||||
CHECK=""
|
||||
for BOARD in $BOARDS; do
|
||||
CHECK="${CHECK} -e ${BOARD}"
|
||||
# Array, not a string: board names reach pio as literal arguments, so an override containing
|
||||
# whitespace or a glob character cannot turn into extra pio arguments.
|
||||
CHECK=()
|
||||
for BOARD in "${BOARDS[@]}"; do
|
||||
CHECK+=(-e "${BOARD}")
|
||||
done
|
||||
|
||||
pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" $CHECK --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high
|
||||
LOG=$(mktemp)
|
||||
trap 'rm -f "$LOG"' EXIT
|
||||
|
||||
# Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the
|
||||
# post-mortem classification below.
|
||||
pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG"
|
||||
STATUS=${PIPESTATUS[0]}
|
||||
|
||||
if [[ $STATUS -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fail closed: if cppcheck got far enough to report anything, the run is real no matter what other
|
||||
# noise is in the log.
|
||||
if grep -Eqi '^Component[[:space:]]+.*HIGH|^Total[[:space:]]+[0-9]|^[^[:space:]]+:[0-9]+: \[(high|medium|low)' "$LOG"; then
|
||||
exit "$STATUS"
|
||||
fi
|
||||
|
||||
# Transport-level failures only. No 403/404/UnknownPackageError here, on purpose.
|
||||
TRANSIENT='requests\.exceptions\.(ConnectionError|Timeout|ReadTimeout|ConnectTimeout)|ConnectionResetError|urllib3\.exceptions\.(ProtocolError|MaxRetryError|NameResolutionError|ReadTimeoutError)|Read timed out|Connection aborted|Connection refused|Temporary failure in name resolution|Could not resolve host|(429|500|502|503|504) (Server|Client) Error|Too Many Requests'
|
||||
|
||||
if ! grep -Eq "$TRANSIENT" "$LOG"; then
|
||||
exit "$STATUS"
|
||||
fi
|
||||
|
||||
REASON=$(grep -Em1 "$TRANSIENT" "$LOG" | tr -d '\r' | cut -c1-200)
|
||||
|
||||
echo "RESULT: SKIPPED ${BOARDS[*]} - transient network failure: ${REASON}"
|
||||
|
||||
if [[ ${GITHUB_ACTIONS:-false} == "true" ]]; then
|
||||
echo "::warning title=Static analysis skipped (${BOARDS[*]})::pio check could not download its packages: ${REASON}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 2
|
||||
|
||||
248
docs/node_info_stores.md
Normal file
248
docs/node_info_stores.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# NodeInfo stores: the base and extended databases
|
||||
|
||||
This document is an overview of the node-identity and traffic-state databases that the
|
||||
TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but
|
||||
only three form the identity lookup chain:
|
||||
|
||||
1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1).
|
||||
2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees
|
||||
(identity tier 2).
|
||||
3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full
|
||||
`User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in
|
||||
native tests.
|
||||
|
||||
The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping
|
||||
state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only
|
||||
its 4-bit cached role acts as a final fallback when all three identity tiers miss.
|
||||
|
||||
Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
|
||||
`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`.
|
||||
|
||||
---
|
||||
|
||||
## 1. NodeDB hot store (authoritative)
|
||||
|
||||
- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as
|
||||
flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`;
|
||||
position/telemetry live in satellite stores reached via copy-out accessors, not nested
|
||||
members). Everything else in this document is a cache or a fallback for it.
|
||||
- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic
|
||||
ESP32, 10 on STM32WL (see `mesh-pb-constants.h`).
|
||||
- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction
|
||||
the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the
|
||||
warm record is rehydrated back (`take()`), including the signer bit.
|
||||
- **Persistence:** the node database file, saved on the usual NodeDB cadence.
|
||||
- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer
|
||||
provenance, and identity content all originate here. The lookup helpers that other
|
||||
stores mirror:
|
||||
- `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference
|
||||
for caches; never consults opportunistic caches.
|
||||
- `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort**
|
||||
(extends the encrypt-to pool for nodes both tiers have forgotten).
|
||||
- `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm.
|
||||
- `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive
|
||||
signed", across hot + warm. Gates that check only the hot store would let a
|
||||
warm-evicted signer be impersonated with unsigned frames.
|
||||
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
|
||||
|
||||
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
|
||||
|
||||
- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal
|
||||
record survives so DMs keep encrypting: the key is expensive to re-learn; everything
|
||||
else rebuilds from traffic in seconds.
|
||||
- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits
|
||||
of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected
|
||||
category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
|
||||
- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered).
|
||||
- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless
|
||||
candidates never displace keyed entries.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
(append/replay/compact); everywhere else `/prefs/warm.dat`.
|
||||
- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes
|
||||
the warm record when the node is re-admitted hot, restoring role/protected/signer bits.
|
||||
|
||||
## 3. TMM unified cache (base, traffic state)
|
||||
|
||||
- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the
|
||||
per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two
|
||||
piggybacked caches:
|
||||
- `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter
|
||||
decisions (no TTL; keeps the slot alive across sweeps).
|
||||
- a **4-bit device role** (split across the top bits of two count bytes) - the _third_
|
||||
fallback for role-aware policy after the hot store and warm tier, surviving even total
|
||||
NodeDB eviction. Read through `resolveSenderRole()`, refreshed by
|
||||
`updateCachedRoleFromNodeInfo()` on observed NodeInfo.
|
||||
- **Entry layout:**
|
||||
`node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)`
|
||||
= 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles)
|
||||
with presence carried by non-zero sentinels - no epochs, no absolute time.
|
||||
- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 /
|
||||
native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap
|
||||
headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable.
|
||||
- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry,
|
||||
preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`)
|
||||
role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred`
|
||||
test covers both, not just `next_hop`).
|
||||
- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic.
|
||||
|
||||
## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier)
|
||||
|
||||
- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see
|
||||
Availability) - the full cached `User` payload (names, role, key) plus the metadata that
|
||||
backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of
|
||||
NodeDB (the serve/throttle behaviour is documented in
|
||||
[traffic_management_module.md](traffic_management_module.md)). Also the last-resort key
|
||||
source for `NodeDB::copyPublicKey()`.
|
||||
- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000
|
||||
entries is too large for MCU internal RAM), plus native unit-test builds on the plain
|
||||
heap so the trust/retention paths run in CI.
|
||||
- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick),
|
||||
`sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`,
|
||||
`keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
|
||||
no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module
|
||||
doc.)
|
||||
- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is
|
||||
low-rate).
|
||||
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
|
||||
seeding plus observed traffic after every boot.
|
||||
|
||||
### Trust & provenance model
|
||||
|
||||
- **Key pin, three layers deep:** an incoming NodeInfo key is checked against
|
||||
`copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own
|
||||
pin), and, failing NodeDB knowledge, against the cache's **own previously cached key**
|
||||
(TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key
|
||||
is dropped outright (impersonation).
|
||||
- **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified
|
||||
(`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same
|
||||
key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it.
|
||||
- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever
|
||||
verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and
|
||||
warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a
|
||||
signer evicted to the warm tier would otherwise be forgeable with its own public key
|
||||
until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s
|
||||
unsigned-broadcast drop.)
|
||||
- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps
|
||||
`obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - they
|
||||
can never make a silent node look alive to the replay path. The 6 h serve window is
|
||||
enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that gate
|
||||
feeds lives in the module (see [traffic_management_module.md](traffic_management_module.md)).
|
||||
|
||||
### Consistency with NodeDB (anti-entropy)
|
||||
|
||||
Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than
|
||||
overwrite**, so a keyless commit never costs the cache a learned TOFU key.
|
||||
|
||||
| Mechanism | When | Role |
|
||||
| --------------------------------------------------------------------- | --------------------------- | -------------------------------- |
|
||||
| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert |
|
||||
| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers |
|
||||
| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds |
|
||||
| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches |
|
||||
|
||||
Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**;
|
||||
and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup
|
||||
each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still
|
||||
marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive,
|
||||
independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and
|
||||
`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to
|
||||
an hour.
|
||||
|
||||
**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by
|
||||
trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally
|
||||
refuses to churn one member out for another (`spareMembers`).
|
||||
|
||||
**Key-commit funnel:** every path that writes a remote key into the hot store must route
|
||||
the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key
|
||||
commits (admin-channel learn in `Router::perhapsDecode`, manual verification in
|
||||
`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an
|
||||
explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this
|
||||
cache). Never assign `info->public_key` directly when **learning or rotating a remote
|
||||
key** - the cache would silently diverge until the next reconcile. (The lone direct write
|
||||
in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm
|
||||
tier already holds, which this cache already tracks as a member, so nothing new is learned
|
||||
and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.)
|
||||
|
||||
**Enable gate:** the write-through hooks, the sweep, the packet path, **and the
|
||||
`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management`
|
||||
is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces
|
||||
(not just documents) the corollary that the pubkey-pool superset property holds only while the
|
||||
module is enabled: a disabled module's frozen cache never feeds PKI resolution or name
|
||||
rehydration.
|
||||
|
||||
### Tick clocks and wrap safety
|
||||
|
||||
All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular
|
||||
subtraction is correct only while the true age stays below the counter period, so every clock
|
||||
needs something to clear expired state before it aliases.
|
||||
|
||||
| Clock | Tick / period | Window | Kept honest by |
|
||||
| ------------------ | -------------- | --------------- | -------------------------------------------------- |
|
||||
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) |
|
||||
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) |
|
||||
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
|
||||
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
|
||||
|
||||
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
|
||||
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
|
||||
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
|
||||
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
|
||||
a build that has the cache always has its sweep.
|
||||
|
||||
The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds
|
||||
timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches
|
||||
chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries.
|
||||
|
||||
### Direct-response behavior
|
||||
|
||||
How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates,
|
||||
the per-requester/per-target/global throttle, and the "throttled forwards, not dropped"
|
||||
behaviour - is documented with the module in
|
||||
[traffic_management_module.md](traffic_management_module.md).
|
||||
|
||||
---
|
||||
|
||||
## Property matrix
|
||||
|
||||
Side-by-side view of what each store actually holds ("-" = not held). Details and
|
||||
rationale live in the per-store sections above.
|
||||
|
||||
| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) |
|
||||
| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) |
|
||||
| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - |
|
||||
| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) |
|
||||
| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks |
|
||||
| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact |
|
||||
| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) |
|
||||
| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none |
|
||||
| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap |
|
||||
|
||||
## How a lookup falls through the tiers
|
||||
|
||||
```text
|
||||
identity/role/key consumer
|
||||
│
|
||||
▼
|
||||
1. hot store (NodeInfoLite) full identity, authoritative
|
||||
│ miss
|
||||
▼
|
||||
2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted
|
||||
│ miss
|
||||
▼
|
||||
3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral
|
||||
│ miss (role-only: 4-bit role in the unified cache)
|
||||
▼
|
||||
defaults (no key; role = CLIENT)
|
||||
```
|
||||
|
||||
The unified cache (§3) sits beside this chain rather than in it: it is traffic-shaping
|
||||
state keyed by the same NodeNum, whose role bits act as the final role fallback when all
|
||||
three identity tiers miss.
|
||||
193
docs/traffic_management_module.md
Normal file
193
docs/traffic_management_module.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# The Traffic Management Module (TMM)
|
||||
|
||||
TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get
|
||||
noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all
|
||||
burn limited airtime and power - and TMM filters or answers that traffic before it is
|
||||
rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to
|
||||
true) with position dedup running at its 11 h default; the other features each default off, so
|
||||
the module is on out of the box but opt-in per feature. It was introduced in
|
||||
[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358).
|
||||
|
||||
This document covers the module's behaviour, with a deep dive on the two TMM-specific
|
||||
NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf)
|
||||
and the **throttling** that bounds it. The identity/traffic-state stores those features read
|
||||
from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns
|
||||
the direct-serve and throttle behaviour, that file owns the stores.
|
||||
|
||||
Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in
|
||||
`src/mesh/Default.h`.
|
||||
|
||||
---
|
||||
|
||||
## How it runs
|
||||
|
||||
- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the
|
||||
`MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime
|
||||
`moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the
|
||||
packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors
|
||||
all no-op - content, maintenance, and reads are keyed to the same condition.
|
||||
- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from
|
||||
`handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it
|
||||
proceed through normal relay handling.
|
||||
- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte
|
||||
`UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick
|
||||
stamps, a next-hop hint, and a 4-bit role fallback) - see
|
||||
[node_info_stores.md §3](node_info_stores.md). Direct-serve additionally reads the PSRAM
|
||||
NodeInfo payload cache (or the NodeDB fallback when that cache is absent).
|
||||
|
||||
## What it does
|
||||
|
||||
| Feature | Default | In one line |
|
||||
| ------------------------ | -------------- | -------------------------------------------------------------- |
|
||||
| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. |
|
||||
| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. |
|
||||
| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. |
|
||||
| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). |
|
||||
| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. |
|
||||
|
||||
Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the
|
||||
exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections
|
||||
after these.
|
||||
|
||||
### Position dedup
|
||||
|
||||
`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the
|
||||
same sender inside the interval, where "duplicate" means the same fingerprint on the channel's
|
||||
`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_
|
||||
the interval: **tracker / TAK tracker → 1 h**, **lost-and-found → 15 min**.
|
||||
|
||||
### Per-sender rate limit
|
||||
|
||||
`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a
|
||||
sender's transit packets once it exceeds the budget within the window.
|
||||
|
||||
### Unknown-packet filter
|
||||
|
||||
`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it
|
||||
passes the threshold within a ~5 min window.
|
||||
|
||||
### NodeInfo direct response
|
||||
|
||||
`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already
|
||||
holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full
|
||||
round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle
|
||||
that bounds it are covered in the two dedicated sections below.
|
||||
|
||||
### Position precision clamp
|
||||
|
||||
Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default).
|
||||
`alterReceived()` truncates relayed position coordinates to that precision.
|
||||
|
||||
### Shelved
|
||||
|
||||
Present in the config surface but currently no-ops in the module, deferred until the right
|
||||
heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` /
|
||||
`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop
|
||||
handling untouched.
|
||||
|
||||
---
|
||||
|
||||
## NodeInfo direct response (direct-serve)
|
||||
|
||||
Normally a unicast NodeInfo request travels all the way to the target and the reply travels
|
||||
all the way back. On a large mesh that is several hops of airtime per lookup. When
|
||||
`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity
|
||||
answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop.
|
||||
|
||||
**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed;
|
||||
full cached `User` plus provenance metadata) or, on builds without that cache, from the
|
||||
NodeDB fallback. Both are described in [node_info_stores.md §4](node_info_stores.md); this
|
||||
feature is a _consumer_ of them.
|
||||
|
||||
**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false`
|
||||
and the request is left to propagate normally:
|
||||
|
||||
1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`,
|
||||
`NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us.
|
||||
2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the
|
||||
role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be
|
||||
lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`).
|
||||
3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path).
|
||||
4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve
|
||||
window. Only a real observed frame stamps the recency bit - seeding and write-through are
|
||||
knowledge, not observation, so a silent node can never look alive to this path.
|
||||
5. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
|
||||
an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from
|
||||
NodeDB). A trust-on-first-use identity is left for the genuine node - or another
|
||||
cache-holder that _has_ proof - to answer. Bypassed when PKI is compiled out.
|
||||
6. **Throttle** (`directResponseAllowed()`): see the next section.
|
||||
|
||||
**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_
|
||||
(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only),
|
||||
`request_id` the original packet id, and the OK_TO_MQTT bit set from local
|
||||
`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is
|
||||
**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an
|
||||
identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually
|
||||
sent.
|
||||
|
||||
---
|
||||
|
||||
## Throttling direct responses
|
||||
|
||||
A direct reply is addressed to the requesting packet's `from` and spoofs the requested
|
||||
target - and **both fields are unauthenticated header data**. Without a bound, an attacker
|
||||
crafts requests carrying a victim's address as `from`, and every neighbour holding the target
|
||||
transmits at the victim: a reflector-amplification primitive. The throttle is the security
|
||||
core of this feature, checked immediately before a reply would go out so requests declined for
|
||||
other reasons never consume the budget.
|
||||
|
||||
**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`:
|
||||
|
||||
| Bound | Window | Bounds |
|
||||
| ------------------------------------------------ | ------ | ------------------------------------------------ |
|
||||
| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive |
|
||||
| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity |
|
||||
| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution |
|
||||
|
||||
**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM**
|
||||
(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave
|
||||
identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike.
|
||||
Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no
|
||||
tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester,
|
||||
target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis
|
||||
throttles never consumes the other axis's budget - then records the send on all three bounds.
|
||||
The global floor is a single stamp, checked first as the cheap common case.
|
||||
|
||||
**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the
|
||||
**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU
|
||||
victim is by construction the entry closest to expiring anyway, so eviction is the
|
||||
lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy,
|
||||
since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the
|
||||
cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a
|
||||
single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key
|
||||
throttling degrades gracefully to the floor under pressure.
|
||||
|
||||
**Throttled is not dropped.** A throttled request returns `false`, which lets
|
||||
`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can
|
||||
answer itself) rather than being black-holed. A requester whose first reply was lost on a
|
||||
noisy link would otherwise get silence for the whole window; repeats of the same packet id
|
||||
are already absorbed by the router's duplicate detection.
|
||||
|
||||
**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in
|
||||
each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global
|
||||
stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes
|
||||
were unified into the symmetric per-requester + per-target RAM tables above, aligned to a
|
||||
single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer
|
||||
carries throttle state.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the
|
||||
`has_traffic_management` presence flag, and each per-feature section above lists its own
|
||||
field(s) and default. Two related sets of knobs are **firmware constants, not config**: the
|
||||
role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and
|
||||
`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve
|
||||
throttle windows (the `kDirectResponse*Ms` constants).
|
||||
|
||||
## See also
|
||||
|
||||
- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo
|
||||
payload cache, and unified cache that the direct-serve path reads from, plus their trust,
|
||||
provenance, and anti-entropy model.
|
||||
@@ -71,3 +71,99 @@ def mtjson_esp32_part(target, source, env):
|
||||
|
||||
|
||||
env.AddPreAction("mtjson", mtjson_esp32_part)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridCompile cache-poisoning workaround (pioarduino platform-espressif32)
|
||||
#
|
||||
# The platform decides whether the precompiled Arduino IDF libs can be reused
|
||||
# by hashing only the custom_sdkconfig text + mcu
|
||||
# (builder/frameworks/arduino.py::matching_custom_sdkconfig). The board JSON's
|
||||
# PSRAM configuration (BOARD_HAS_PSRAM / memory_type) is not part of that
|
||||
# hash, and nearly all our S3 variants share esp32s3_base's identical
|
||||
# custom_sdkconfig. Building a PSRAM env (e.g. heltec-v4) followed by a
|
||||
# no-PSRAM env (e.g. heltec-v3) therefore skips the framework-libs recompile
|
||||
# and links CONFIG_SPIRAM=y libs into the no-PSRAM firmware, which boot-loops
|
||||
# with "quad_psram: PSRAM chip is not connected".
|
||||
#
|
||||
# Workaround: before the platform's arduino.py SConscript reads the option,
|
||||
# append a comment line derived from the board's PSRAM/memory configuration to
|
||||
# this env's custom_sdkconfig. The comment is inert in kconfig but changes the
|
||||
# md5, forcing the IDF-libs recompile whenever the PSRAM class changes. The
|
||||
# derivation mirrors espidf.py::generate_board_specific_config().
|
||||
#
|
||||
# The marker must stay lowercase: arduino.py greps custom_sdkconfig for the
|
||||
# substrings "PSRAM" and "CONFIG_SPIRAM=y" (has_psram_config) and would
|
||||
# otherwise treat every board as having PSRAM.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SDKCONFIG_CACHE_KEY = "meshtastic_hybridcompile_cache_key"
|
||||
|
||||
|
||||
def spiram_cache_class(env):
|
||||
board = env.BoardConfig()
|
||||
mcu = board.get("build.mcu", "esp32")
|
||||
|
||||
# memory_type: platformio.ini override > build.arduino.memory_type > build.memory_type
|
||||
memory_type = None
|
||||
try:
|
||||
memory_type = env.GetProjectOption("board_build.memory_type", None)
|
||||
except Exception:
|
||||
pass
|
||||
if not memory_type:
|
||||
build_section = board.get("build", {})
|
||||
memory_type = build_section.get("arduino", {}).get(
|
||||
"memory_type"
|
||||
) or build_section.get("memory_type")
|
||||
|
||||
extra_flags = board.get("build.extra_flags", "")
|
||||
if isinstance(extra_flags, str):
|
||||
has_psram = "PSRAM" in extra_flags
|
||||
else:
|
||||
has_psram = any("PSRAM" in str(flag) for flag in extra_flags)
|
||||
if not has_psram:
|
||||
if memory_type and (
|
||||
"opi" in memory_type.lower() or "psram" in memory_type.lower()
|
||||
):
|
||||
has_psram = True
|
||||
elif "psram_type" in board.get("build", {}):
|
||||
has_psram = True
|
||||
|
||||
if has_psram:
|
||||
psram_type = None
|
||||
try:
|
||||
psram_type = env.GetProjectOption("board_build.psram_type", None)
|
||||
except Exception:
|
||||
pass
|
||||
if not psram_type and memory_type and len(memory_type.split("_")) == 2:
|
||||
psram_type = memory_type.split("_")[1]
|
||||
if not psram_type:
|
||||
psram_type = board.get("build.psram_type", "") or (
|
||||
"hex" if mcu == "esp32p4" else "qio"
|
||||
)
|
||||
psram_type = psram_type.lower()
|
||||
if psram_type == "opi" and mcu == "esp32s3":
|
||||
spiram = "oct"
|
||||
elif psram_type == "hex":
|
||||
spiram = "hex"
|
||||
else:
|
||||
spiram = "quad"
|
||||
else:
|
||||
spiram = "none"
|
||||
|
||||
return "memory_type=%s spiram=%s" % ((memory_type or "default").lower(), spiram)
|
||||
|
||||
|
||||
def tag_sdkconfig_cache_key(env):
|
||||
config = env.GetProjectConfig()
|
||||
section = "env:" + env["PIOENV"]
|
||||
if not config.has_option(section, "custom_sdkconfig"):
|
||||
return
|
||||
current = env.GetProjectOption("custom_sdkconfig")
|
||||
if SDKCONFIG_CACHE_KEY in current:
|
||||
return
|
||||
marker = "# %s: %s" % (SDKCONFIG_CACHE_KEY, spiram_cache_class(env))
|
||||
config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker)
|
||||
|
||||
|
||||
tag_sdkconfig_cache_key(env)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# trunk-ignore-all(bandit/B404): subprocess is used to call addr2line
|
||||
# trunk-ignore-all(bandit/B603): subprocess is used to call addr2line
|
||||
|
||||
# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
@@ -54,6 +54,7 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DRADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-DMESHTASTIC_EXCLUDE_DROPZONE=1
|
||||
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
|
||||
-DMESHTASTIC_EXCLUDE_RANGETEST=1
|
||||
-DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
|
||||
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
@@ -122,12 +123,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/10e7925db2727e1f5c1c796191905d0a7d955eec.zip
|
||||
https://github.com/jgromes/RadioLib/archive/6d8934836678d8894e3d556550475b37dce3e2b6.zip
|
||||
|
||||
[device-ui_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
|
||||
https://github.com/meshtastic/device-ui/archive/8d007e0c7ff17f9435905725bf06ee487c2a5d87.zip
|
||||
https://github.com/meshtastic/device-ui/archive/6968b23e5270dccb2fc8531a7a37687131354fa8.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
|
||||
Submodule protobufs updated: b6ad0e5f4e...bfd718fa1d
@@ -10,13 +10,11 @@
|
||||
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): Import/env/Return are SCons-injected globals
|
||||
# trunk-ignore-all(ruff/E402)
|
||||
# trunk-ignore-all(flake8/E402): stdlib imports must follow Import("env")
|
||||
Import("env")
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
Import("env")
|
||||
|
||||
framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico")
|
||||
if not framework_dir:
|
||||
print("[add_mbedtls_sources] framework-arduinopico package not found - skipping")
|
||||
|
||||
@@ -868,7 +868,6 @@ void Power::reboot()
|
||||
Wire.end();
|
||||
Serial1.end();
|
||||
if (screen) {
|
||||
delete screen;
|
||||
screen = nullptr;
|
||||
}
|
||||
LOG_DEBUG("final reboot!");
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Only for cases where we can know it (ESP32 or known screen) we can do this.
|
||||
*/
|
||||
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
class ReClockI2C
|
||||
{
|
||||
|
||||
@@ -223,6 +223,44 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Everest Semiconductor ES7210 4-channel audio ADC, as found on the T-Deck. Its AD1/AD0 straps
|
||||
// select 0x40..0x43, so it lands squarely on the INA/SHT2X addresses. Chip ID registers and their
|
||||
// reset values, per the ES7210 datasheet rev 2.0.
|
||||
static constexpr uint8_t ES7210_CHIP_ID1_REG = 0x3D;
|
||||
static constexpr uint8_t ES7210_CHIP_ID1 = 0x72;
|
||||
static constexpr uint8_t ES7210_CHIP_ID0_REG = 0x3E;
|
||||
static constexpr uint8_t ES7210_CHIP_ID0 = 0x10;
|
||||
|
||||
static bool readByteRegister(TwoWire *i2cBus, uint8_t address, uint8_t reg, uint8_t &value)
|
||||
{
|
||||
i2cBus->beginTransmission(address);
|
||||
i2cBus->write(reg);
|
||||
|
||||
if (i2cBus->endTransmission() != 0)
|
||||
return false;
|
||||
|
||||
if (i2cBus->requestFrom(address, (uint8_t)1) != 1 || !i2cBus->available())
|
||||
return false;
|
||||
|
||||
value = i2cBus->read();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The INA219 has no manufacturer or die ID register to check, so it is inferred from "something
|
||||
// answered here and it wasn't anything else we know". That makes positively identifying the other
|
||||
// occupants of this address the only way to keep them out of the fallback.
|
||||
static bool detectES7210(TwoWire *i2cBus, uint8_t address)
|
||||
{
|
||||
uint8_t id = 0;
|
||||
|
||||
// Read the high ID byte first so anything that clearly isn't an ES7210 costs a single
|
||||
// transaction, then confirm with the low byte.
|
||||
if (!readByteRegister(i2cBus, address, ES7210_CHIP_ID1_REG, id) || id != ES7210_CHIP_ID1)
|
||||
return false;
|
||||
|
||||
return readByteRegister(i2cBus, address, ES7210_CHIP_ID0_REG, id) && id == ES7210_CHIP_ID0;
|
||||
}
|
||||
|
||||
#define SCAN_SIMPLE_CASE(ADDR, T, ...) \
|
||||
case ADDR: \
|
||||
logFoundDevice(__VA_ARGS__); \
|
||||
@@ -479,13 +517,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
type = INA260;
|
||||
}
|
||||
}
|
||||
|
||||
// The ES7210 audio ADC shares this address on some boards (T-Deck). It answers
|
||||
// none of the checks above, so identify it here and leave the type unset - driving
|
||||
// an audio codec as if it were a power monitor crashes the device. See #11115.
|
||||
if (type == NONE && detectES7210(i2cBus, (uint8_t)addr.address)) {
|
||||
LOG_INFO("ES7210 audio codec at 0x%x, not a power sensor", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
|
||||
if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) {
|
||||
logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address);
|
||||
type = SHTXX;
|
||||
}
|
||||
#endif
|
||||
else { // Assume INA219 if none of the above ones are found
|
||||
// Guarded on the type rather than chained to the SHT2X check above, so that a
|
||||
// positively identified INA226/INA260 isn't overwritten right after being found.
|
||||
if (type == NONE) { // Assume INA219 if none of the above ones are found
|
||||
logFoundDevice("INA219", (uint8_t)addr.address);
|
||||
type = INA219;
|
||||
}
|
||||
@@ -864,15 +912,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
|
||||
case 0x48: {
|
||||
i2cBus->beginTransmission(addr.address);
|
||||
uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
|
||||
uint8_t expectedInfo[] = {0xa5, 0xE0, 0x00, 0x3F, 0x19};
|
||||
uint8_t info[5];
|
||||
// T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is
|
||||
// required: readBytes() only drains the RX buffer requestFrom() fills.
|
||||
const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
|
||||
const uint8_t expectedInfo[] = {0xA5, 0xE0, 0x00, 0x3F, 0x19};
|
||||
uint8_t info[sizeof(expectedInfo)] = {0};
|
||||
size_t len = 0;
|
||||
i2cBus->write(getInfo, 5);
|
||||
i2cBus->endTransmission();
|
||||
len = i2cBus->readBytes(info, 5);
|
||||
if (len == 5 && memcmp(expectedInfo, info, len) == 0) {
|
||||
bool isSE050 = false;
|
||||
|
||||
i2cBus->beginTransmission(addr.address);
|
||||
i2cBus->write(getInfo, sizeof(getInfo));
|
||||
if (i2cBus->endTransmission() == 0) {
|
||||
delay(2); // guard time before the answer can be read back
|
||||
len = i2cBus->requestFrom((uint8_t)addr.address, (uint8_t)sizeof(info));
|
||||
if (len == sizeof(info)) {
|
||||
for (size_t i = 0; i < sizeof(info); i++)
|
||||
info[i] = i2cBus->read();
|
||||
isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSE050) {
|
||||
LOG_INFO("NXP SE050 crypto chip found");
|
||||
type = NXP_SE050;
|
||||
break;
|
||||
|
||||
@@ -41,9 +41,8 @@ bool HUB75Display::connect()
|
||||
cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity)
|
||||
cfg.latch_blanking = 4; // clean row transitions
|
||||
cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half
|
||||
cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA
|
||||
// footprint. display() draws directly into the
|
||||
// live buffer with a dirty-diff
|
||||
cfg.double_buff = false; // single buffer halves the internal-SRAM DMA
|
||||
// footprint; display() dirty-diffs into the live buffer
|
||||
|
||||
matrix = new MatrixPanel_I2S_DMA(cfg);
|
||||
bool ok = matrix->begin();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "mesh/generated/meshtastic/config.pb.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -842,6 +843,6 @@ class Screen : public concurrency::OSThread
|
||||
// Extern declarations for function symbols used in UIRenderer
|
||||
extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
using namespace meshtastic;
|
||||
|
||||
// External variables
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
extern PowerStatus *powerStatus;
|
||||
extern NodeStatus *nodeStatus;
|
||||
extern GPSStatus *gpsStatus;
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
#include "graphics/emotes.h"
|
||||
#include "main.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// External declarations
|
||||
extern bool hasUnreadMessage;
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
using graphics::Emote;
|
||||
using graphics::emotes;
|
||||
@@ -1073,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
{
|
||||
if (packet.from != 0) {
|
||||
hasUnreadMessage = true;
|
||||
const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
|
||||
|
||||
// Determine if message belongs to a muted channel
|
||||
bool isChannelMuted = false;
|
||||
@@ -1163,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
// Shorter banner if already in a conversation (Channel or Direct)
|
||||
bool inThread = (getThreadMode() != ThreadMode::ALL);
|
||||
|
||||
if (shouldWakeOnReceivedMessage()) {
|
||||
if (!suppressBanner && shouldWakeOnReceivedMessage()) {
|
||||
screen->setOn(true);
|
||||
}
|
||||
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
if (!suppressBanner) {
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always focus into the correct conversation thread when a message with real text arrives
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
// Global screen instance
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
#if defined(OLED_TINY)
|
||||
static uint32_t lastSwitchTime = 0;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
#include <gps/RTC.h>
|
||||
|
||||
// External variables
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
#if defined(OLED_TINY)
|
||||
static uint32_t lastSwitchTime = 0;
|
||||
#endif
|
||||
@@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
{
|
||||
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||||
display->drawXbm(x + 1, y + yOffset, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,9 +521,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
|
||||
{
|
||||
// Draw satellite image
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||||
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
char textString[10];
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0
|
||||
const uint8_t imgSatellite[] PROGMEM = {
|
||||
0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000,
|
||||
};
|
||||
#define imgGPS_width 8
|
||||
#define imgGPS_height 8
|
||||
const unsigned char imgGPS[] PROGMEM = {0x00, 0x07, 0x39, 0x2D, 0xFF, 0x48, 0x48, 0x60};
|
||||
|
||||
const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc};
|
||||
const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc,
|
||||
|
||||
@@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
|
||||
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
|
||||
{
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return;
|
||||
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
p->to = dest;
|
||||
p->channel = channel;
|
||||
@@ -2622,7 +2624,7 @@ void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
// Tack on a bell character if requested
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
|
||||
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
|
||||
p->decoded.payload.size++;
|
||||
|
||||
30
src/main.cpp
30
src/main.cpp
@@ -217,7 +217,7 @@ using namespace concurrency;
|
||||
volatile static const char slipstreamTZString[] = {USERPREFS_TZ_STRING};
|
||||
|
||||
// We always create a screen object, but we only init it if we find the hardware
|
||||
graphics::Screen *screen = nullptr;
|
||||
std::unique_ptr<graphics::Screen> screen = nullptr;
|
||||
|
||||
// Global power status
|
||||
meshtastic::PowerStatus *powerStatus = new meshtastic::PowerStatus();
|
||||
@@ -988,15 +988,15 @@ void setup()
|
||||
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
|
||||
|
||||
#if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306)
|
||||
screen = new graphics::Screen(screen_found, screen_model, screen_geometry);
|
||||
screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) &&
|
||||
config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
|
||||
screen = new graphics::Screen(screen_found, screen_model, screen_geometry);
|
||||
screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
|
||||
}
|
||||
#else
|
||||
if (screen_found.port != ScanI2C::I2CPort::NO_I2C)
|
||||
screen = new graphics::Screen(screen_found, screen_model, screen_geometry);
|
||||
screen = std::make_unique<graphics::Screen>(screen_found, screen_model, screen_geometry);
|
||||
#endif
|
||||
}
|
||||
#endif // HAS_SCREEN
|
||||
@@ -1133,6 +1133,23 @@ void setup()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(SENSECAP_INDICATOR)
|
||||
// The ST7701 panel shares SCK/MOSI/MISO (41/48/47) with the SX1262, and its host is SPI2_HOST,
|
||||
// which on the S3 is the same peripheral as the Arduino `SPI` object (FSPI == SPI2).
|
||||
// LovyanGFX bit-bangs the ST7701 init sequence on those pins, and because this variant builds
|
||||
// with USE_ARDUINO_HAL_GPIO it does so via Arduino pinMode()/digitalWrite(). pinMode() calls
|
||||
// perimanSetPinBus(.., ESP32_BUS_TYPE_GPIO, ..), whose deinit callback (spiDetachBus_SCK) ends
|
||||
// up in spiStopBus() and gates the SPI2 clock. RadioLib then spins forever in spiTransferByte()
|
||||
// waiting on cmd.update, which never clears on a stopped peripheral, and the watchdog fires.
|
||||
//
|
||||
// Restart the bus here, after the panel is up and before the radio is touched. Note that
|
||||
// SPIClass::begin() early-returns when _spi is already non-NULL, so end() first is required.
|
||||
SPI.end();
|
||||
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, -1); // CS is an IO-expander pin, driven by RadioLib
|
||||
SPI.setFrequency(4000000);
|
||||
LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI);
|
||||
#endif
|
||||
|
||||
auto rIf = initLoRa();
|
||||
|
||||
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
|
||||
@@ -1284,6 +1301,8 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
#if !defined(HAS_RGB_LED) && !RAK_4631
|
||||
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG;
|
||||
#endif
|
||||
// Range test is always excluded as of 2.8
|
||||
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG;
|
||||
|
||||
// No bluetooth on these targets (yet):
|
||||
// Pico W / 2W may get it at some point
|
||||
@@ -1300,6 +1319,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
deviceMetadata.hasPKC = true;
|
||||
#endif
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
deviceMetadata.has_xeddsa = true;
|
||||
#endif
|
||||
return deviceMetadata;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "mesh/generated/meshtastic/telemetry.pb.h"
|
||||
#include <SPI.h>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
||||
#include "nimble/NimbleBluetooth.h"
|
||||
extern NimbleBluetooth *nimbleBluetooth;
|
||||
@@ -68,7 +69,7 @@ extern UdpMulticastHandler *udpHandler;
|
||||
#endif
|
||||
|
||||
// Global Screen singleton.
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER
|
||||
#include "motion/AccelerometerThread.h"
|
||||
|
||||
@@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
|
||||
LOG_DEBUG("Repeated reliable tx");
|
||||
// Check if it's still in the Tx queue, if not, we have to relay it again
|
||||
if (!findInTxQueue(p->from, p->id)) {
|
||||
reprocessPacket(p);
|
||||
perhapsRebroadcast(p);
|
||||
if (reprocessPacket(p))
|
||||
perhapsRebroadcast(p);
|
||||
}
|
||||
} else {
|
||||
perhapsCancelDupe(p);
|
||||
@@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
|
||||
{
|
||||
// isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages
|
||||
if (isRebroadcaster() && iface && p->hop_limit > 0) {
|
||||
// Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue.
|
||||
// This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper
|
||||
// safe if another caller is introduced later.
|
||||
if (passesRoutingAuthGate(const_cast<meshtastic_MeshPacket *>(p)) != RoutingAuthVerdict::ACCEPT)
|
||||
return true;
|
||||
|
||||
// If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to
|
||||
// rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead.
|
||||
uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining
|
||||
@@ -75,7 +81,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
|
||||
LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id,
|
||||
p->hop_limit, dropThreshold);
|
||||
|
||||
reprocessPacket(p);
|
||||
if (!reprocessPacket(p))
|
||||
return true;
|
||||
perhapsRebroadcast(p);
|
||||
|
||||
rxDupe++;
|
||||
@@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p)
|
||||
return false;
|
||||
}
|
||||
|
||||
void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p)
|
||||
bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p)
|
||||
{
|
||||
if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
||||
auto decodedState = perhapsDecode(const_cast<meshtastic_MeshPacket *>(p));
|
||||
if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nodeDB)
|
||||
nodeDB->updateFrom(*p);
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
|
||||
if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
||||
// If we got a packet that is not decoded, try to decode it so we can check for traceroute.
|
||||
auto decodedState = perhapsDecode(const_cast<meshtastic_MeshPacket *>(p));
|
||||
if (decodedState == DecodeState::DECODE_SUCCESS) {
|
||||
// parsing was successful, print for debugging
|
||||
printPacket("reprocessPacket(DUP)", p);
|
||||
} else {
|
||||
// Fatal decoding error, we can't do anything with this packet
|
||||
LOG_WARN(
|
||||
"FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute",
|
||||
static_cast<int>(decodedState), p->id, getFrom(p));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) {
|
||||
traceRouteModule->processUpgradedPacket(*p);
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)
|
||||
|
||||
@@ -64,7 +64,7 @@ class FloodingRouter : public Router
|
||||
bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p);
|
||||
|
||||
/* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */
|
||||
void reprocessPacket(const meshtastic_MeshPacket *p);
|
||||
bool reprocessPacket(const meshtastic_MeshPacket *p);
|
||||
|
||||
// Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of
|
||||
// the same packet
|
||||
@@ -75,4 +75,4 @@ class FloodingRouter : public Router
|
||||
|
||||
// Return true if we are a rebroadcaster
|
||||
bool isRebroadcaster();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -114,7 +114,10 @@ template <class T> class MemoryDynamic : public Allocator<T>
|
||||
virtual T *alloc(TickType_t maxWait) override
|
||||
{
|
||||
T *p = (T *)malloc(sizeof(T));
|
||||
assert(p);
|
||||
if (!p) {
|
||||
LOG_WARN("malloc(%u) failed, heap exhausted!", (unsigned)sizeof(T));
|
||||
return nullptr;
|
||||
}
|
||||
this->auditAdd((int32_t)sizeof(T));
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
|
||||
*/
|
||||
meshtastic_MeshPacket *MeshModule::currentReply;
|
||||
|
||||
MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum)
|
||||
{
|
||||
// Can't trust static initializer order, so we check each time
|
||||
if (!modules)
|
||||
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
modules->push_back(this);
|
||||
}
|
||||
|
||||
bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp)
|
||||
{
|
||||
return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
mp.decoded.portnum == modulePort;
|
||||
}
|
||||
|
||||
void MeshModule::setup() {}
|
||||
|
||||
MeshModule::~MeshModule()
|
||||
@@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod
|
||||
// So we manually call pb_encode_to_bytes and specify routing port number
|
||||
// auto p = allocDataProtobuf(c);
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
p->decoded.portnum = meshtastic_PortNum_ROUTING_APP;
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c);
|
||||
@@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
auto &pi = **i;
|
||||
|
||||
pi.currentRequest = ∓
|
||||
pi.ignoreRequest = false;
|
||||
|
||||
/// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious)
|
||||
bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp);
|
||||
@@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
// better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like
|
||||
// any other node.
|
||||
if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) {
|
||||
pi.sendResponse(mp);
|
||||
if (replyPortMatches(pi.ourPortNum, mp)) {
|
||||
pi.sendResponse(mp);
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum);
|
||||
}
|
||||
ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' considered", pi.name);
|
||||
}
|
||||
@@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus()
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -68,10 +68,12 @@ class MeshModule
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
MeshModule(const char *_name);
|
||||
MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
|
||||
|
||||
virtual ~MeshModule();
|
||||
|
||||
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
|
||||
|
||||
/** For use only by MeshService
|
||||
*/
|
||||
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
|
||||
@@ -88,6 +90,7 @@ class MeshModule
|
||||
#endif
|
||||
protected:
|
||||
const char *name;
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
/** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific
|
||||
recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those
|
||||
@@ -103,7 +106,7 @@ class MeshModule
|
||||
* flag */
|
||||
bool encryptedOk = false;
|
||||
|
||||
/* We allow modules to ignore a request without sending an error if they have a specific reason for it. */
|
||||
/* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */
|
||||
bool ignoreRequest = false;
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,12 +137,17 @@ void MeshService::loop()
|
||||
/// The radioConfig object just changed, call this to force the hw to change to the new settings
|
||||
void MeshService::reloadConfig(int saveWhat)
|
||||
{
|
||||
// If we can successfully set this radio to these settings, save them to disk
|
||||
// Only LoRa config and channels (freq/PSK/slot) affect the radio. Saves that only touch
|
||||
// module config, device state, or the node database (e.g. favoriting a node) have no reason
|
||||
// to re-init the LoRa chip - skip it there to avoid an unnecessary and risky SPI reconfigure.
|
||||
if (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)) {
|
||||
// If we can successfully set this radio to these settings, save them to disk
|
||||
|
||||
// This will also update the region as needed
|
||||
nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings
|
||||
// This will also update the region as needed
|
||||
nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings
|
||||
|
||||
configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc
|
||||
configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc
|
||||
}
|
||||
nodeDB->saveToDisk(saveWhat);
|
||||
}
|
||||
|
||||
@@ -319,13 +324,20 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh
|
||||
uint32_t mesh_packet_id = p->id;
|
||||
nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...)
|
||||
|
||||
// callModules' loopback gate keeps RX_SRC_LOCAL packets from RoutingModule, the only module
|
||||
// that forwards to the phone, so deliver our own reply's copy here or the client never sees it.
|
||||
const bool localDelivery = isToUs(p);
|
||||
if (src == RX_SRC_LOCAL && localDelivery)
|
||||
ccToPhone = true;
|
||||
|
||||
// Note: We might return !OK if our fifo was full, at that point the only option we have is to drop it
|
||||
ErrorCode res = router->sendLocal(p, src);
|
||||
|
||||
/* NOTE(pboldin): Prepare and send QueueStatus message to the phone as a
|
||||
* high-priority message. */
|
||||
meshtastic_QueueStatus qs = router->getQueueStatus();
|
||||
ErrorCode r = sendQueueStatusToPhone(qs, res, mesh_packet_id);
|
||||
// SHOULD_RELEASE means "caller frees", not a send failure, so don't report it as one.
|
||||
ErrorCode r = sendQueueStatusToPhone(qs, (res == ERRNO_SHOULD_RELEASE && localDelivery) ? ERRNO_OK : res, mesh_packet_id);
|
||||
if (r != ERRNO_OK) {
|
||||
LOG_DEBUG("Can't send status to phone");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
|
||||
NextHopRouter::NextHopRouter() {}
|
||||
|
||||
bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
|
||||
{
|
||||
// Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK
|
||||
// handling. Relay only from the immutable outer routing header and let hop exhaustion bound it.
|
||||
const auto mode = config.device.rebroadcast_mode;
|
||||
if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed ||
|
||||
!IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) ||
|
||||
(p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
|
||||
return false;
|
||||
|
||||
meshtastic_MeshPacket *relay = packetPool.allocCopy(*p);
|
||||
if (!relay)
|
||||
return false;
|
||||
relay->hop_limit--;
|
||||
relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum());
|
||||
return Router::send(relay) == ERRNO_OK;
|
||||
}
|
||||
|
||||
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
|
||||
{
|
||||
packet = p;
|
||||
@@ -65,16 +84,15 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
|
||||
LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node);
|
||||
// Check if it's still in the Tx queue, if not, we have to relay it again
|
||||
if (!findInTxQueue(p->from, p->id)) {
|
||||
reprocessPacket(p);
|
||||
perhapsRebroadcast(p);
|
||||
if (reprocessPacket(p))
|
||||
perhapsRebroadcast(p);
|
||||
}
|
||||
} else {
|
||||
bool isRepeated = getHopsAway(*p) == 0;
|
||||
// If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again
|
||||
if (isRepeated) {
|
||||
if (!findInTxQueue(p->from, p->id)) {
|
||||
reprocessPacket(p);
|
||||
if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
|
||||
if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) {
|
||||
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter
|
||||
* @return true to abandon the packet
|
||||
*/
|
||||
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override;
|
||||
bool relayOpaquePacket(const meshtastic_MeshPacket *p) override;
|
||||
|
||||
/**
|
||||
* Look for packets we need to relay
|
||||
@@ -213,4 +214,4 @@ class NextHopRouter : public FloodingRouter
|
||||
/** Check if we should be rebroadcasting this packet if so, do so.
|
||||
* @return true if we did rebroadcast */
|
||||
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#include "xmodem.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <algorithm>
|
||||
@@ -767,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
|
||||
warmStore.clear();
|
||||
warmStore.saveIfDirty();
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
|
||||
// identities (the device usually reboots after this, but don't rely on it).
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
// second, install default state (this will deal with the duplicate mac address issue)
|
||||
installDefaultNodeDatabase();
|
||||
@@ -948,6 +957,12 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
|
||||
config.security.admin_key_count = numAdminKeys;
|
||||
|
||||
// Left at COMPATIBLE when signature checking is compiled out, so we never report a policy
|
||||
// nothing enforces (mirrors the set-config guard in AdminModule).
|
||||
#if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
config.security.packet_signature_policy = USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY;
|
||||
#endif
|
||||
|
||||
if (shouldPreserveKey) {
|
||||
config.security.private_key.size = 32;
|
||||
memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size);
|
||||
@@ -1597,6 +1612,11 @@ void NodeDB::resetNodes(bool keepFavorites)
|
||||
#if WARM_NODE_COUNT > 0
|
||||
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
devicestate.has_rx_waypoint = false;
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -1629,6 +1649,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
|
||||
// Explicit user removal: don't let the warm tier resurrect the node
|
||||
warmStore.remove(nodeNum);
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Explicit removal is full removal: the TrafficManagement caches (unified slot +
|
||||
// NodeInfo identity cache) must not keep serving or resurrect the node either.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeNode(nodeNum);
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -1841,6 +1867,8 @@ bool NodeDB::enforceSatelliteCaps()
|
||||
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
|
||||
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
|
||||
{
|
||||
if (nodeInfoLiteHasXeddsaSigned(&n))
|
||||
return static_cast<uint8_t>(WarmProtected::XeddsaSigner);
|
||||
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
||||
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
|
||||
return static_cast<uint8_t>(WarmProtected::Flag);
|
||||
@@ -2980,6 +3008,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
||||
moduleConfig.has_audio = true;
|
||||
moduleConfig.has_paxcounter = true;
|
||||
moduleConfig.has_statusmessage = true;
|
||||
moduleConfig.has_tak = true;
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
moduleConfig.has_mesh_beacon = true;
|
||||
#endif
|
||||
@@ -3120,6 +3149,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
|
||||
#include "MeshModule.h"
|
||||
#include "Throttle.h"
|
||||
|
||||
// Minimum spacing between evictions once the node database is full.
|
||||
#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL)
|
||||
|
||||
static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
|
||||
|
||||
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
|
||||
@@ -3312,15 +3344,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
||||
*/
|
||||
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
|
||||
{
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||
if (!info) {
|
||||
// Only a signed update may change the identity of a node that has proven it signs; our own record is
|
||||
// exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier.
|
||||
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
|
||||
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
|
||||
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Once a node has proven it signs, only a signed update may change its identity. The public-key guard
|
||||
// below is no help - an attacker can replay the victim's real (public) key. Our own record is exempt.
|
||||
if (nodeId != getNodeNum() && nodeInfoLiteHasXeddsaSigned(info) && !xeddsaSigned) {
|
||||
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||
if (!info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3400,6 +3433,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
||||
}
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
|
||||
// MeshService, and TMM's requester learning all funnel through updateUser; the two
|
||||
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
|
||||
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
|
||||
// acceptance, not on `changed`: an identical update still proves the identity is
|
||||
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
|
||||
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
|
||||
if (nodeId != getNodeNum() && trafficManagementModule) {
|
||||
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
|
||||
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
|
||||
}
|
||||
#endif
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -3414,7 +3461,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
||||
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) {
|
||||
LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time);
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp));
|
||||
// mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise
|
||||
// invented node numbers churn it at packet rate and push real neighbours out.
|
||||
meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp));
|
||||
if (!info) {
|
||||
if (isFull()) {
|
||||
if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) {
|
||||
LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from);
|
||||
return;
|
||||
}
|
||||
lastFullEvictionMs = millis();
|
||||
}
|
||||
info = getOrCreateMeshNode(getFrom(&mp));
|
||||
}
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
@@ -3698,7 +3757,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info && info->public_key.size == 32) {
|
||||
@@ -3714,6 +3773,97 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
if (copyPublicKeyAuthoritative(n, out))
|
||||
return true;
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
|
||||
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
|
||||
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
|
||||
// same first-contact trust NodeDB itself applies via updateUser().
|
||||
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
|
||||
out.size = 32;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
if (copyPublicKeyAuthoritative(n, out))
|
||||
return true;
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// A cold-tier cache key backs an authenticated decrypt only when signer-proven; unverified TOFU
|
||||
// cache keys must not. Outbound encryption still uses the opportunistic copyPublicKey().
|
||||
bool signerProven = false;
|
||||
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &signerProven) && signerProven) {
|
||||
out.size = 32;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
|
||||
{
|
||||
if (!key32)
|
||||
return false;
|
||||
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
|
||||
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info)
|
||||
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
|
||||
#if WARM_NODE_COUNT > 0
|
||||
uint8_t warmKey[32];
|
||||
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
|
||||
return warmStore.isVerifiedSigner(n);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::isKnownXeddsaSigner(NodeNum n)
|
||||
{
|
||||
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info)
|
||||
return nodeInfoLiteHasXeddsaSigned(info);
|
||||
#if WARM_NODE_COUNT > 0
|
||||
return warmStore.isVerifiedSigner(n);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
|
||||
{
|
||||
if (!key32 || n == 0)
|
||||
return;
|
||||
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
|
||||
// verification re-committing an already-stored key), and memcpy forbids overlap.
|
||||
uint8_t key[32];
|
||||
memcpy(key, key32, 32);
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
|
||||
if (!info)
|
||||
return;
|
||||
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
|
||||
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
|
||||
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
|
||||
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
|
||||
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
|
||||
memcpy(info->public_key.bytes, key, 32);
|
||||
info->public_key.size = 32;
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
|
||||
// NodeInfo cache diverges until the next hourly reconcile.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
|
||||
#endif
|
||||
}
|
||||
|
||||
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
@@ -3809,8 +3959,27 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
lite->public_key.size = 32;
|
||||
memcpy(lite->public_key.bytes, warm.public_key, 32);
|
||||
}
|
||||
if (warmProtOf(warm) == static_cast<uint8_t>(WarmProtected::XeddsaSigner))
|
||||
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
|
||||
}
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
|
||||
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
|
||||
// cache is much larger and often still holds the full User. Restore it - but only when
|
||||
// its cached key matches the key we just restored from warm, so a name never attaches to
|
||||
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
|
||||
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
|
||||
// user-related bits, so the warm-restored signer bit survives.
|
||||
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
|
||||
meshtastic_User tmmUser = meshtastic_User_init_zero;
|
||||
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
|
||||
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
|
||||
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
|
||||
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
|
||||
}
|
||||
|
||||
@@ -360,6 +360,37 @@ class NodeDB
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
|
||||
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
|
||||
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Key for the inbound-decrypt path: authoritative (hot/warm), or a cold-tier cache key only when
|
||||
/// it is signer-proven. Keeps unverified TOFU cache keys from backing pki_encrypted attribution.
|
||||
bool copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
|
||||
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
|
||||
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
|
||||
|
||||
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
|
||||
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
|
||||
bool isKnownXeddsaSigner(NodeNum n);
|
||||
|
||||
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
|
||||
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
|
||||
/// only ManuallyVerified vouches for possession of exactly this key.
|
||||
enum class KeyCommitTrust : uint8_t {
|
||||
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
|
||||
ManuallyVerified, // the user confirmed possession of exactly this key
|
||||
};
|
||||
|
||||
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
|
||||
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
|
||||
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
|
||||
/// site must call this rather than assigning info->public_key, or the TrafficManagement
|
||||
/// cache silently diverges until the next hourly reconcile.
|
||||
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
|
||||
|
||||
/// Resolve a node's device role - hot store (with user) first, then the role
|
||||
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
|
||||
/// nodes that have aged out of the hot store.
|
||||
@@ -529,9 +560,10 @@ class NodeDB
|
||||
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
|
||||
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
|
||||
bool configDecodeFailed = false;
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
|
||||
/*
|
||||
* Internal boolean to track sorting paused
|
||||
|
||||
@@ -887,6 +887,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter;
|
||||
break;
|
||||
case meshtastic_ModuleConfig_statusmessage_tag:
|
||||
LOG_DEBUG("Send module config: status message");
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
|
||||
fromRadioScratch.moduleConfig.payload_variant.statusmessage = moduleConfig.statusmessage;
|
||||
break;
|
||||
case meshtastic_ModuleConfig_traffic_management_tag:
|
||||
LOG_DEBUG("Send module config: traffic management");
|
||||
fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
|
||||
|
||||
@@ -44,6 +44,8 @@ template <class T> class ProtobufModule : protected SinglePortModule
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload);
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
@@ -67,6 +68,79 @@ Allocator<meshtastic_MeshPacket> &packetPool = staticPool;
|
||||
|
||||
static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__));
|
||||
|
||||
struct RoutingAuthCache {
|
||||
bool valid = false;
|
||||
// Deliberately NOT initialized in-class as this eats flash space.
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy;
|
||||
meshtastic_MeshPacket wire = meshtastic_MeshPacket_init_zero;
|
||||
meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero;
|
||||
};
|
||||
static RoutingAuthCache routingAuthCache;
|
||||
static concurrency::Lock *routingAuthCacheLock;
|
||||
static uint32_t routingAuthEvaluations;
|
||||
|
||||
static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet)
|
||||
{
|
||||
if (!routingAuthCacheLock)
|
||||
return false;
|
||||
concurrency::LockGuard guard(routingAuthCacheLock);
|
||||
if (!routingAuthCache.valid)
|
||||
return false;
|
||||
if (routingAuthCache.policy != config.security.packet_signature_policy ||
|
||||
memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) != 0) {
|
||||
routingAuthCache.valid = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated)
|
||||
{
|
||||
concurrency::LockGuard guard(routingAuthCacheLock);
|
||||
routingAuthCache.wire = wire;
|
||||
routingAuthCache.authenticated = authenticated;
|
||||
routingAuthCache.policy = config.security.packet_signature_policy;
|
||||
routingAuthCache.valid = true;
|
||||
}
|
||||
|
||||
static bool applyRoutingAuthCache(meshtastic_MeshPacket *packet)
|
||||
{
|
||||
if (!routingAuthCacheLock)
|
||||
return false;
|
||||
concurrency::LockGuard guard(routingAuthCacheLock);
|
||||
if (!routingAuthCache.valid || routingAuthCache.policy != config.security.packet_signature_policy ||
|
||||
memcmp(&routingAuthCache.wire, packet, sizeof(*packet)) != 0) {
|
||||
routingAuthCache.valid = false;
|
||||
return false;
|
||||
}
|
||||
*packet = routingAuthCache.authenticated;
|
||||
routingAuthCache.valid = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void clearRoutingAuthCache()
|
||||
{
|
||||
if (!routingAuthCacheLock)
|
||||
return;
|
||||
concurrency::LockGuard guard(routingAuthCacheLock);
|
||||
routingAuthCache.valid = false;
|
||||
}
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
uint32_t routingAuthEvaluationCount()
|
||||
{
|
||||
return routingAuthEvaluations;
|
||||
}
|
||||
void resetRoutingAuthEvaluationCount()
|
||||
{
|
||||
routingAuthEvaluations = 0;
|
||||
if (routingAuthCacheLock) {
|
||||
concurrency::LockGuard guard(routingAuthCacheLock);
|
||||
routingAuthCache.valid = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
@@ -85,6 +159,10 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA
|
||||
// init Lockguard for crypt operations
|
||||
assert(!cryptLock);
|
||||
cryptLock = new concurrency::Lock();
|
||||
if (!routingAuthCacheLock)
|
||||
routingAuthCacheLock = new concurrency::Lock();
|
||||
// Runtime default for the auth-cache snapshot policy. Keep it here, saves flash.
|
||||
routingAuthCache.policy = meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED;
|
||||
}
|
||||
|
||||
bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
||||
@@ -194,6 +272,8 @@ PacketId generatePacketId()
|
||||
meshtastic_MeshPacket *Router::allocForSending()
|
||||
{
|
||||
meshtastic_MeshPacket *p = packetPool.allocZeroed();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start.
|
||||
p->from = nodeDB->getNodeNum();
|
||||
@@ -247,8 +327,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
|
||||
// No need to deliver externally if the destination is the local node
|
||||
if (isToUs(p)) {
|
||||
printPacket("Enqueued local", p);
|
||||
enqueueReceivedMessage(p);
|
||||
return ERRNO_OK;
|
||||
// Preserve the trusted origin explicitly. Queueing used to erase src and make a local
|
||||
// phone/module packet indistinguishable from remote already-decoded ingress.
|
||||
deliverLocal(p, src);
|
||||
return ERRNO_SHOULD_RELEASE;
|
||||
} else if (!iface) {
|
||||
// We must be sending to remote nodes also, fail if no interface found
|
||||
abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p);
|
||||
@@ -256,9 +338,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
|
||||
return ERRNO_NO_INTERFACES;
|
||||
} else {
|
||||
// If we are sending a broadcast, we also treat it as if we just received it ourself
|
||||
// this allows local apps (and PCs) to see broadcasts sourced locally
|
||||
// this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback
|
||||
// handleReceived is deferred when nested; send(p) below still transmits immediately.
|
||||
if (isBroadcast(p->to)) {
|
||||
handleReceived(p, src);
|
||||
deliverLocal(p, src);
|
||||
}
|
||||
|
||||
// don't override if a channel was requested and no need to set it when PKI is enforced
|
||||
@@ -499,18 +582,55 @@ static bool canonicalSignableSize(meshtastic_Data *d, size_t *size)
|
||||
return pb_get_encoded_size(size, &meshtastic_Data_msg, d);
|
||||
}
|
||||
|
||||
enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID };
|
||||
|
||||
static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p)
|
||||
{
|
||||
if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP)
|
||||
return NodeInfoBootstrapResult::NOT_APPLICABLE;
|
||||
|
||||
meshtastic_User user = meshtastic_User_init_zero;
|
||||
if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) ||
|
||||
user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from ||
|
||||
!crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) {
|
||||
return NodeInfoBootstrapResult::INVALID;
|
||||
}
|
||||
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (!node)
|
||||
return NodeInfoBootstrapResult::INVALID;
|
||||
node->public_key.size = user.public_key.size;
|
||||
memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size);
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
p->xeddsa_signed = true;
|
||||
LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from);
|
||||
return NodeInfoBootstrapResult::VERIFIED;
|
||||
}
|
||||
|
||||
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
{
|
||||
const auto policy = config.security.packet_signature_policy;
|
||||
const bool strict = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
|
||||
const bool compatible = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
||||
|
||||
// Only a signature we verify below may mark this packet signed; never trust an inbound flag.
|
||||
p->xeddsa_signed = false;
|
||||
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
||||
meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}};
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && node->public_key.size == 32) {
|
||||
if (nodeDB->copyPublicKey(p->from, senderKey)) {
|
||||
p->xeddsa_signed =
|
||||
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
|
||||
if (p->xeddsa_signed) {
|
||||
// Learn this node as a signer, so a later unsigned signable broadcast from it is dropped
|
||||
// A warm-tier key must be re-admitted before setting the signer bit; otherwise Balanced
|
||||
// forgets downgrade protection as soon as the node is evicted from the hot store.
|
||||
if (!node)
|
||||
node = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (!node)
|
||||
return false;
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
||||
} else {
|
||||
@@ -518,7 +638,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const auto bootstrap = verifyFirstContactNodeInfo(p);
|
||||
if (bootstrap == NodeInfoBootstrapResult::INVALID) {
|
||||
LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from);
|
||||
return false;
|
||||
}
|
||||
if (bootstrap == NodeInfoBootstrapResult::VERIFIED)
|
||||
return true;
|
||||
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
|
||||
if (strict)
|
||||
return false;
|
||||
}
|
||||
} else if (p->decoded.xeddsa_signature.size != 0) {
|
||||
// A signature field that is neither empty nor a full 64 bytes is malformed - honest
|
||||
@@ -529,15 +658,24 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
p->from);
|
||||
return false;
|
||||
} else {
|
||||
// Truly unsigned (signature size 0) - only reject the class a signing node always signs: a
|
||||
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
|
||||
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
|
||||
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
|
||||
// fields the Data carried, with padding hidden inside Data.payload stripped. Unicast/PKI
|
||||
// packets and broadcasts too big to carry a signature are never signed, so they must not be
|
||||
// hard-failed here even for a known signer.
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
|
||||
if (p->pki_encrypted)
|
||||
return true;
|
||||
if (strict) {
|
||||
LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from);
|
||||
return false;
|
||||
}
|
||||
if (compatible)
|
||||
return true;
|
||||
|
||||
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a
|
||||
// signing node always signs: a non-PKI broadcast whose signed encoding would still fit the
|
||||
// LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the
|
||||
// sender-side signedDataFits() gate, so this counts the same fields that gate counted.
|
||||
// Unicast packets and broadcasts too big to carry a signature are never signed, so they
|
||||
// must not be hard-failed here even for a known signer (PKI already returned above).
|
||||
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
|
||||
// must not become impersonatable via unsigned broadcasts until it is re-heard.
|
||||
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
|
||||
size_t canonicalSize;
|
||||
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
||||
return true; // can't size it; never drop on a sizing failure
|
||||
@@ -551,6 +689,103 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
}
|
||||
#endif
|
||||
|
||||
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p)
|
||||
{
|
||||
// Routing still needs the original encrypted representation for byte-for-byte relay and for
|
||||
// MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode
|
||||
// only after stateful routing filters have completed.
|
||||
if (routingAuthCacheMatches(*p))
|
||||
return RoutingAuthVerdict::ACCEPT;
|
||||
|
||||
meshtastic_MeshPacket wire = *p;
|
||||
meshtastic_MeshPacket authCandidate = *p;
|
||||
routingAuthEvaluations++;
|
||||
if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
|
||||
// Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a
|
||||
// decryptor. Never trust serialized local authentication metadata on that boundary.
|
||||
authCandidate.pki_encrypted = false;
|
||||
authCandidate.public_key.size = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!checkXeddsaReceivePolicy(&authCandidate)) {
|
||||
LOG_WARN("Already-decoded packet rejected by signature policy");
|
||||
return RoutingAuthVerdict::REJECT;
|
||||
}
|
||||
#endif
|
||||
p->xeddsa_signed = authCandidate.xeddsa_signed;
|
||||
wire = *p;
|
||||
storeRoutingAuthCache(wire, authCandidate);
|
||||
return RoutingAuthVerdict::ACCEPT;
|
||||
}
|
||||
const DecodeState state = perhapsDecode(&authCandidate);
|
||||
if (state == DecodeState::DECODE_POLICY_REJECT) {
|
||||
LOG_WARN("Packet rejected by signature policy");
|
||||
return RoutingAuthVerdict::REJECT;
|
||||
}
|
||||
if (state == DecodeState::DECODE_FATAL) {
|
||||
LOG_WARN("Fatal decode error, dropping packet");
|
||||
return RoutingAuthVerdict::REJECT;
|
||||
}
|
||||
if (state == DecodeState::DECODE_FAILURE) {
|
||||
LOG_WARN("Decryptable packet failed decoding, dropping packet");
|
||||
return RoutingAuthVerdict::REJECT;
|
||||
}
|
||||
|
||||
// Only an explicit unknown-channel result remains eligible for opaque relay.
|
||||
if (state == DecodeState::DECODE_OPAQUE)
|
||||
return RoutingAuthVerdict::OPAQUE_RELAY_ONLY;
|
||||
storeRoutingAuthCache(wire, authCandidate);
|
||||
return RoutingAuthVerdict::ACCEPT;
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
|
||||
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
|
||||
#define ADMIN_KEY_FALLBACK_BURST 8
|
||||
#define ADMIN_KEY_FALLBACK_REFILL_MS 250
|
||||
|
||||
static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
|
||||
static uint32_t adminKeyFallbackRefillMs = 0;
|
||||
|
||||
static bool adminKeyFallbackAllowed()
|
||||
{
|
||||
bool haveAdminKey = false;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (config.security.admin_key[i].size == 32) {
|
||||
haveAdminKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!haveAdminKey)
|
||||
return false; // nothing to try, so do not spend a token
|
||||
|
||||
uint32_t now = millis();
|
||||
if (adminKeyFallbackRefillMs == 0)
|
||||
adminKeyFallbackRefillMs = now;
|
||||
uint32_t elapsed = now - adminKeyFallbackRefillMs;
|
||||
if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) {
|
||||
uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS;
|
||||
adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS;
|
||||
if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens)
|
||||
adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
|
||||
else
|
||||
adminKeyFallbackTokens += refill;
|
||||
}
|
||||
|
||||
if (adminKeyFallbackTokens == 0)
|
||||
return false;
|
||||
|
||||
adminKeyFallbackTokens--;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void adminKeyFallbackRefund()
|
||||
{
|
||||
if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST)
|
||||
adminKeyFallbackTokens++;
|
||||
}
|
||||
#endif
|
||||
|
||||
DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
@@ -564,39 +799,62 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)
|
||||
return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return
|
||||
|
||||
// Authentication metadata is local-only. Re-establish it below only after successful PKI decryption.
|
||||
p->pki_encrypted = false;
|
||||
p->public_key.size = 0;
|
||||
|
||||
size_t rawSize = p->encrypted.size;
|
||||
if (rawSize > sizeof(bytes)) {
|
||||
LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize);
|
||||
return DecodeState::DECODE_FATAL;
|
||||
}
|
||||
bool decrypted = false;
|
||||
bool pkiAttempted = false;
|
||||
bool matchedChannel = false;
|
||||
ChannelIndex chIndex = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
|
||||
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
|
||||
meshtastic_NodeInfoLite *ourNode = nullptr;
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
||||
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
||||
pkiAttempted = true;
|
||||
LOG_DEBUG("Attempt PKI decryption");
|
||||
// Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel
|
||||
// packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large
|
||||
// NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is
|
||||
// signer-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from)
|
||||
// DM attribution.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKeyForDecrypt(p->from, remotePublic);
|
||||
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
|
||||
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
|
||||
bool havePendingKey = false;
|
||||
if (!haveRemoteKey) {
|
||||
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
haveRemoteKey = havePendingKey;
|
||||
}
|
||||
// Try the sender's known key first, then each configured admin key so an authorized admin can
|
||||
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
|
||||
bool viaAdminKey = false;
|
||||
bool viaPendingKey = false;
|
||||
if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaPendingKey = havePendingKey;
|
||||
}
|
||||
for (int i = 0; i < 3 && !decrypted; i++) {
|
||||
if (config.security.admin_key[i].size != 32)
|
||||
continue;
|
||||
remotePublic.size = 32;
|
||||
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
|
||||
if (!decrypted && adminKeyFallbackAllowed()) {
|
||||
for (int i = 0; i < 3 && !decrypted; i++) {
|
||||
if (config.security.admin_key[i].size != 32)
|
||||
continue;
|
||||
remotePublic.size = 32;
|
||||
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
|
||||
|
||||
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaAdminKey = true;
|
||||
break; // stop after first successful decryption
|
||||
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaAdminKey = true;
|
||||
break; // stop after first successful decryption
|
||||
}
|
||||
}
|
||||
if (decrypted)
|
||||
adminKeyFallbackRefund();
|
||||
}
|
||||
if (decrypted) {
|
||||
LOG_INFO("PKI Decryption worked!");
|
||||
@@ -605,6 +863,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD;
|
||||
if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) &&
|
||||
decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) {
|
||||
if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) {
|
||||
// The pending key only proves the handshake initiator holds it, not that they are
|
||||
// p->from. Beyond the exchange it would let them send DMs that look authenticated.
|
||||
LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
decrypted = true;
|
||||
rawSize = payloadSize; // commit the overhead subtraction only on full success
|
||||
LOG_INFO("Packet decrypted using PKI!");
|
||||
@@ -615,10 +879,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||
if (viaAdminKey) {
|
||||
// Persist the admin key for the sender so future packets take the fast path and we can
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it.
|
||||
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (fromNode != nullptr)
|
||||
fromNode->public_key = remotePublic;
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
|
||||
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
|
||||
// User-payload path deliberately and handles the TrafficManagement write-through.
|
||||
// AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
|
||||
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
|
||||
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
|
||||
}
|
||||
} else {
|
||||
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
|
||||
@@ -636,6 +902,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) {
|
||||
// Try to use this hash/channel pair
|
||||
if (channels.decryptForHash(chIndex, p->channel)) {
|
||||
matchedChannel = true;
|
||||
// we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a
|
||||
// fresh copy for each decrypt attempt.
|
||||
memcpy(bytes, p->encrypted.bytes, rawSize);
|
||||
@@ -671,10 +938,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
p->channel = chIndex; // change to store the index instead of the hash
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
// Runs before the bitfield merge below: that merge can set want_response, adding wire bytes
|
||||
// the sender never encoded and skewing the policy's sizing of p->decoded.
|
||||
// Run before merging local-only bitfield state into the decoded Data.
|
||||
if (!checkXeddsaReceivePolicy(p))
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
return DecodeState::DECODE_POLICY_REJECT;
|
||||
#endif
|
||||
|
||||
if (p->decoded.has_bitfield)
|
||||
@@ -732,7 +998,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
return DecodeState::DECODE_SUCCESS;
|
||||
} else {
|
||||
LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
return (matchedChannel || pkiAttempted) ? DecodeState::DECODE_FAILURE : DecodeState::DECODE_OPAQUE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +1016,34 @@ static bool signedDataFits(meshtastic_Data *d)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey)
|
||||
{
|
||||
// First, only PKC encrypt packets we are originating
|
||||
return isFromUs(p) &&
|
||||
#if ARCH_PORTDUINO
|
||||
// Sim radio via the cli flag skips PKC
|
||||
!portduino_config.force_simradio &&
|
||||
#endif
|
||||
// Don't use PKC with Ham mode
|
||||
!owner.is_licensed &&
|
||||
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
|
||||
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
|
||||
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
|
||||
// Check for valid keys and single node destination
|
||||
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
|
||||
// Some portnums either make no sense to send with PKC
|
||||
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
|
||||
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
|
||||
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
|
||||
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
|
||||
// uses the pending key resolved into haveDestKey/destKey above.
|
||||
// Though possible the first packet each direction should go non-pkc
|
||||
// to handle the case where the remote node has our key, but we don't have theirs.
|
||||
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey);
|
||||
}
|
||||
#endif
|
||||
|
||||
/** Return 0 for success or a Routing_Error code for failure
|
||||
*/
|
||||
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
@@ -843,28 +1137,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
}
|
||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||
// is not in the local nodedb
|
||||
// First, only PKC encrypt packets we are originating
|
||||
if (isFromUs(p) &&
|
||||
#if ARCH_PORTDUINO
|
||||
// Sim radio via the cli flag skips PKC
|
||||
!portduino_config.force_simradio &&
|
||||
#endif
|
||||
// Don't use PKC with Ham mode
|
||||
!owner.is_licensed &&
|
||||
// Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested
|
||||
!(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 ||
|
||||
strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) &&
|
||||
// Check for valid keys and single node destination
|
||||
config.security.private_key.size == 32 && !isBroadcast(p->to) &&
|
||||
// Some portnums either make no sense to send with PKC
|
||||
p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP &&
|
||||
p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP &&
|
||||
// We allow Key Verification messages to be sent without a known destination key, since the point of those messages is
|
||||
// to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet
|
||||
// uses the pending key resolved into haveDestKey/destKey above.
|
||||
// Though possible the first packet each direction should go non-pkc
|
||||
// to handle the case where the remote node has our key, but we don't have theirs.
|
||||
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) {
|
||||
if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
|
||||
LOG_DEBUG("Use PKI!");
|
||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||
return meshtastic_Routing_Error_TOO_LARGE;
|
||||
@@ -929,31 +1202,123 @@ NodeNum Router::getNodeNum()
|
||||
return nodeDB->getNodeNum();
|
||||
}
|
||||
|
||||
bool Router::enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src)
|
||||
{
|
||||
if (deferredLocalCount >= deferredLocalCapacity)
|
||||
return false;
|
||||
uint8_t tail = (deferredLocalHead + deferredLocalCount) % deferredLocalCapacity;
|
||||
deferredLocalQueue[tail].p = p;
|
||||
deferredLocalQueue[tail].src = src;
|
||||
deferredLocalCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Router::dequeueDeferredLocal(DeferredLocal &out)
|
||||
{
|
||||
if (deferredLocalCount == 0)
|
||||
return false;
|
||||
out = deferredLocalQueue[deferredLocalHead];
|
||||
deferredLocalHead = (deferredLocalHead + 1) % deferredLocalCapacity;
|
||||
deferredLocalCount--;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src)
|
||||
{
|
||||
// Top level: handle synchronously, exactly as before the depth guard existed.
|
||||
if (handleDepth == 0) {
|
||||
handleReceived(p, src);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nested: a module sent this from inside callModules(). Defer a copy so the outermost
|
||||
// handleReceived() drains it once the current dispatch unwinds, instead of stacking another
|
||||
// handleReceived() frame on top of the module handler (nRF52 stack overflow on config save).
|
||||
meshtastic_MeshPacket *copy = packetPool.allocCopy(*p);
|
||||
if (copy && enqueueDeferredLocal(copy, src))
|
||||
return;
|
||||
|
||||
// Pool exhausted or queue full: drop the deferral. Leak-free and degraded but safe - the
|
||||
// packet still followed its normal non-loopback path (SHOULD_RELEASE, or the TX path for a
|
||||
// broadcast). Mirrors sendToPhone()'s degrade-on-exhaustion behavior.
|
||||
if (copy)
|
||||
packetPool.release(copy);
|
||||
LOG_WARN("Deferred local queue full/alloc failed, dropping loopback of 0x%08x", p->id);
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
deferredLocalDropped++;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle any packet that is received by an interface on this node.
|
||||
* Note: some packets may merely being passed through this node and will be forwarded elsewhere.
|
||||
*/
|
||||
void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||
{
|
||||
handleDepth++;
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
if (handleDepth > maxHandleDepthObserved)
|
||||
maxHandleDepthObserved = handleDepth;
|
||||
#endif
|
||||
|
||||
dispatchReceived(p, src);
|
||||
|
||||
// Only the outermost frame drains. Deferred packets were produced by modules sending from
|
||||
// inside dispatchReceived()'s callModules(); process them here, after the triggering frame has
|
||||
// unwound, so a second handleReceived() never sits on top of a module handler. handleDepth
|
||||
// stays >= 1 through the drain, so a drained packet whose own modules send more loopback
|
||||
// packets enqueues them for this same loop rather than recursing: the stack stays flat and
|
||||
// processing is breadth-first.
|
||||
if (handleDepth == 1) {
|
||||
DeferredLocal d;
|
||||
while (dequeueDeferredLocal(d)) {
|
||||
dispatchReceived(d.p, d.src);
|
||||
packetPool.release(d.p);
|
||||
}
|
||||
}
|
||||
|
||||
handleDepth--;
|
||||
}
|
||||
|
||||
void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||
{
|
||||
bool skipHandle = false;
|
||||
// Also, we should set the time from the ISR and it should have msec level resolution
|
||||
p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone
|
||||
|
||||
// Store a copy of the encrypted packet for MQTT.
|
||||
// Local, not a class member: handleReceived re-enters itself when a module
|
||||
// reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal,
|
||||
// and a member would be silently overwritten without release on the inner
|
||||
// call. Each invocation now owns its own copy (issue #9632, #10101, #8729).
|
||||
// Kept as a local (not a class member) so each dispatch owns its own copy. A shared member was
|
||||
// historically overwritten without release when a module's reply re-entered this path through
|
||||
// MeshService::sendToMesh -> Router::sendLocal (issues #9632, #10101, #8729). Nested local
|
||||
// sends are now deferred rather than synchronously re-entrant (see the drain in
|
||||
// handleReceived()), so this no longer strictly needs to be a local, but it is kept per-call.
|
||||
DEBUG_HEAP_BEFORE;
|
||||
meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p);
|
||||
DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted);
|
||||
|
||||
// Consume the decoded/authenticated handoff after preserving the exact encrypted packet and
|
||||
// before mutating any packet fields that participate in the exact cache match.
|
||||
if (src == RX_SRC_RADIO)
|
||||
applyRoutingAuthCache(p);
|
||||
|
||||
// Also, we should set the time from the ISR and it should have msec level resolution.
|
||||
// Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp.
|
||||
const uint32_t rxTime = getValidTime(RTCQualityFromNet);
|
||||
p->rx_time = rxTime;
|
||||
if (p_encrypted)
|
||||
p_encrypted->rx_time = rxTime;
|
||||
|
||||
// Take those raw bytes and convert them back into a well structured protobuf we can understand
|
||||
auto decodedState = perhapsDecode(p);
|
||||
if (decodedState == DecodeState::DECODE_FATAL) {
|
||||
if (decodedState == DecodeState::DECODE_FATAL || decodedState == DecodeState::DECODE_POLICY_REJECT ||
|
||||
decodedState == DecodeState::DECODE_FAILURE) {
|
||||
// Fatal decoding error, we can't do anything with this packet
|
||||
LOG_WARN("Fatal decode error, dropping packet");
|
||||
cancelSending(p->from, p->id);
|
||||
LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT
|
||||
? "Packet rejected by signature policy"
|
||||
: (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet"
|
||||
: "Decryptable packet failed decoding, dropping packet"));
|
||||
// A policy rejection is attacker-controlled input and must not cancel a valid pending
|
||||
// transmission with the same (from, id). Preserve the pre-existing fatal-decode behavior.
|
||||
if (decodedState == DecodeState::DECODE_FATAL)
|
||||
cancelSending(p->from, p->id);
|
||||
skipHandle = true;
|
||||
} else if (decodedState == DecodeState::DECODE_SUCCESS) {
|
||||
// parsing was successful, queue for our recipient
|
||||
@@ -1029,7 +1394,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
|
||||
} else {
|
||||
// Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not
|
||||
// to us (because we would be able to decrypt it)
|
||||
if (decodedState == DecodeState::DECODE_FAILURE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 &&
|
||||
if (decodedState == DecodeState::DECODE_OPAQUE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 &&
|
||||
!isBroadcast(p->to) && !isToUs(p))
|
||||
p_encrypted->pki_encrypted = true;
|
||||
// After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet
|
||||
@@ -1078,6 +1443,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
||||
#endif
|
||||
// assert(radioConfig.has_preferences);
|
||||
if (is_in_repeated(config.lora.ignore_incoming, p->from)) {
|
||||
clearRoutingAuthCache();
|
||||
LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from);
|
||||
packetPool.release(p);
|
||||
return;
|
||||
@@ -1085,30 +1451,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p)
|
||||
|
||||
meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from);
|
||||
if (nodeInfoLiteIsIgnored(node)) {
|
||||
clearRoutingAuthCache();
|
||||
LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from);
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p->from == NODENUM_BROADCAST) {
|
||||
clearRoutingAuthCache();
|
||||
LOG_DEBUG("Ignore msg from broadcast address");
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.lora.ignore_mqtt && p->via_mqtt) {
|
||||
clearRoutingAuthCache();
|
||||
LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from);
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDropPacketForPreHop(*p)) {
|
||||
clearRoutingAuthCache();
|
||||
logHopStartDrop(*p, "pre-hop drop");
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt and authenticate before Reliable/Flooding/NextHop filters can update retry
|
||||
// timers, packet history, implicit ACK state, cancellation, or relay queues. A packet for
|
||||
// an unknown channel passes as opaque traffic and retains the existing relay behavior.
|
||||
const auto authVerdict = passesRoutingAuthGate(p);
|
||||
if (authVerdict == RoutingAuthVerdict::REJECT) {
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) {
|
||||
relayOpaquePacket(p);
|
||||
packetPool.release(p);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldFilterReceived(p)) {
|
||||
clearRoutingAuthCache();
|
||||
LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from);
|
||||
packetPool.release(p);
|
||||
return;
|
||||
|
||||
@@ -112,6 +112,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
||||
*/
|
||||
virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; }
|
||||
|
||||
/** Relay an opaque packet without admitting it to local routing/history state. */
|
||||
virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; }
|
||||
|
||||
/**
|
||||
* Determine if hop_limit should be decremented for a relay operation.
|
||||
* Returns false (preserve hop_limit) only if all conditions are met:
|
||||
@@ -157,11 +160,66 @@ class Router : protected concurrency::OSThread, protected PacketHistory
|
||||
*/
|
||||
void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO);
|
||||
|
||||
/**
|
||||
* The body of handleReceived(): decode, run modules, publish to MQTT. Split out so the
|
||||
* depth-guarded drain in handleReceived() can process a deferred packet without re-entering
|
||||
* the drain (and without touching handleDepth) - keeping the stack flat.
|
||||
*/
|
||||
void dispatchReceived(meshtastic_MeshPacket *p, RxSource src);
|
||||
|
||||
/**
|
||||
* Route a packet addressed to us (or a local broadcast we loop back) into handleReceived().
|
||||
* Called synchronously at the top level, but if a module sends this from inside callModules()
|
||||
* (handleDepth > 0) the packet is copied into the deferred queue instead, so we never stack a
|
||||
* second handleReceived() on top of a module handler - that nesting is what overflows the
|
||||
* nRF52 task stack on a config save. Does not consume p; the caller's existing free path is
|
||||
* unchanged.
|
||||
*/
|
||||
void deliverLocal(meshtastic_MeshPacket *p, RxSource src);
|
||||
|
||||
/// Depth of handleReceived() frames currently on the stack. >0 means a module is dispatching,
|
||||
/// so a locally-sent loopback packet must be deferred rather than handled synchronously.
|
||||
uint8_t handleDepth = 0;
|
||||
|
||||
/// A local loopback packet whose handleReceived() was deferred because it was produced from
|
||||
/// inside callModules(). The queue owns the packet; its RxSource travels with it so the drain
|
||||
/// dispatches it with the origin the sender intended (RX_SRC_LOCAL stays local).
|
||||
struct DeferredLocal {
|
||||
meshtastic_MeshPacket *p;
|
||||
RxSource src;
|
||||
};
|
||||
|
||||
/// Fixed, small ring buffer of deferred local packets. A config save fans out only a few
|
||||
/// loopback packets (a self-addressed reply plus a nodeinfo/config broadcast or two), so four
|
||||
/// slots cover the realistic nesting. On overflow the deferral is dropped (the packet still
|
||||
/// followed its normal non-loopback path) rather than blocking or growing the heap.
|
||||
static constexpr uint8_t deferredLocalCapacity = 4;
|
||||
DeferredLocal deferredLocalQueue[deferredLocalCapacity];
|
||||
uint8_t deferredLocalHead = 0; // index of the oldest queued entry
|
||||
uint8_t deferredLocalCount = 0; // entries currently queued
|
||||
|
||||
/// Queue a deferred local packet. Returns false (and queues nothing) when full.
|
||||
bool enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src);
|
||||
/// Pop the oldest deferred local packet into out. Returns false when empty.
|
||||
bool dequeueDeferredLocal(DeferredLocal &out);
|
||||
|
||||
/** Frees the provided packet, and generates a NAK indicating the specifed error while sending */
|
||||
void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p);
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
public:
|
||||
/// High-water mark of handleDepth across this Router's life. The deferral must keep it at 1:
|
||||
/// a nested local send may never re-enter handleReceived() synchronously.
|
||||
uint8_t maxHandleDepthObserved = 0;
|
||||
/// Count of deferrals dropped because the queue was full or a copy could not be allocated.
|
||||
uint32_t deferredLocalDropped = 0;
|
||||
/// Number of deferred local packets currently queued.
|
||||
uint8_t deferredLocalPending() const { return deferredLocalCount; }
|
||||
#endif
|
||||
};
|
||||
|
||||
enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
|
||||
enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_OPAQUE, DECODE_FATAL, DECODE_POLICY_REJECT };
|
||||
enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT };
|
||||
|
||||
/** FIXME - move this into a mesh packet class
|
||||
* Remove any encryption and decode the protobufs inside this packet (if necessary).
|
||||
@@ -170,29 +228,35 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL };
|
||||
*/
|
||||
DecodeState perhapsDecode(meshtastic_MeshPacket *p);
|
||||
|
||||
/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */
|
||||
RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p);
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
uint32_t routingAuthEvaluationCount();
|
||||
void resetRoutingAuthEvaluationCount();
|
||||
#endif
|
||||
|
||||
/** Return 0 for success or a Routing_Error code for failure
|
||||
*/
|
||||
meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p);
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the
|
||||
* sender's public key is known, verify it: on success learn the sender's signer bit, on failure
|
||||
* drop. If the key is unknown the signature is left unverified and the packet passes. A signature
|
||||
* of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce
|
||||
* downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would
|
||||
* still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass).
|
||||
*
|
||||
* The fit test sizes p->decoded with the real encoder, so it measures the fields the sender
|
||||
* encoded rather than any raw wire length.
|
||||
*
|
||||
* The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache.
|
||||
* (perhapsDecode already holds it; other call sites must take it themselves.)
|
||||
*
|
||||
* @return false if the packet must be dropped.
|
||||
*/
|
||||
/** Enforce the configured XEdDSA receive policy. The caller must hold cryptLock.
|
||||
* Returns false when the packet must be dropped. */
|
||||
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p);
|
||||
#endif
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
/**
|
||||
* Would perhapsEncode() PKC-encrypt this outgoing packet? Callers that must know the encryption a
|
||||
* packet will get before it is encoded (e.g. pinning a peer key at request time) have to ask this
|
||||
* rather than inspect p, whose pki_encrypted/public_key fields are only populated on the RX path.
|
||||
*
|
||||
* @param chIndex the channel index p carries before encoding rewrites it to a hash.
|
||||
* @param haveDestKey whether a public key for p->to was resolvable.
|
||||
*/
|
||||
bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey);
|
||||
#endif
|
||||
|
||||
extern Router *router;
|
||||
|
||||
/// Generate a unique packet id
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
*/
|
||||
class SinglePortModule : public MeshModule
|
||||
{
|
||||
protected:
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
public:
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {}
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {}
|
||||
|
||||
protected:
|
||||
/**
|
||||
@@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
p->decoded.portnum = ourPortNum;
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
return e && warmSignerOf(*e);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
|
||||
@@ -63,7 +63,7 @@ static constexpr uint32_t WARM_SIGNER_MASK = 0x01u;
|
||||
enum class WarmFormat : uint8_t { Current, V2, V1 };
|
||||
|
||||
// Protected category cached alongside role so consumers needn't re-derive the mapping.
|
||||
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
|
||||
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 };
|
||||
|
||||
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer)
|
||||
{
|
||||
@@ -119,6 +119,10 @@ class WarmNodeStore
|
||||
/// @return false if the node is not in the warm tier.
|
||||
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
|
||||
|
||||
/// True if the warm tier holds this node with its signer bit set (an XEdDSA signature
|
||||
/// was verified from it before eviction).
|
||||
bool isVerifiedSigner(NodeNum num) const;
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
|
||||
@@ -131,6 +135,15 @@ class WarmNodeStore
|
||||
size_t count() const;
|
||||
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
|
||||
|
||||
/// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr
|
||||
/// when the slot is empty or i >= capacity().
|
||||
const WarmNodeEntry *entryAt(size_t i) const
|
||||
{
|
||||
if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0)
|
||||
return nullptr;
|
||||
return &entries[i];
|
||||
}
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
|
||||
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
class UdpMulticastHandler final
|
||||
{
|
||||
public:
|
||||
UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(224, 0, 0, 69); }
|
||||
UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(239, 0, 0, 69); }
|
||||
|
||||
void start()
|
||||
{
|
||||
@@ -80,10 +80,12 @@ class UdpMulticastHandler final
|
||||
return;
|
||||
}
|
||||
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
|
||||
// Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for
|
||||
// PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep
|
||||
// pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly.
|
||||
// Authentication metadata is local-only; Router re-establishes it after successful PKI decryption.
|
||||
mp.pki_encrypted = false;
|
||||
mp.public_key.size = 0;
|
||||
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
|
||||
if (!p)
|
||||
return;
|
||||
// Unset received SNR/RSSI
|
||||
p->rx_snr = 0;
|
||||
p->rx_rssi = 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "AdminModule.h"
|
||||
#include "Channels.h"
|
||||
#include "CryptoEngine.h"
|
||||
#include "DisplayFormatters.h"
|
||||
#include "HardwareRNG.h"
|
||||
#include "MeshService.h"
|
||||
@@ -928,11 +929,15 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
config.power.on_battery_shutdown_after_secs = 30;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_network_tag:
|
||||
case meshtastic_Config_network_tag: {
|
||||
LOG_INFO("Set config: WiFi");
|
||||
config.has_network = true;
|
||||
char prevPsk[sizeof(config.network.wifi_psk)];
|
||||
memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk));
|
||||
config.network = c.payload_variant.network;
|
||||
writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk);
|
||||
break;
|
||||
}
|
||||
case meshtastic_Config_display_tag:
|
||||
LOG_INFO("Set config: Display");
|
||||
config.has_display = true;
|
||||
@@ -1131,6 +1136,16 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
rotated.private_key = incoming.private_key;
|
||||
incoming = rotated;
|
||||
}
|
||||
#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA
|
||||
if (incoming.packet_signature_policy !=
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE) {
|
||||
incoming.packet_signature_policy =
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE;
|
||||
const char *warning = "Packet authenticity policy is unavailable on this firmware build";
|
||||
LOG_WARN(warning);
|
||||
sendWarning(warning);
|
||||
}
|
||||
#endif
|
||||
config.security = incoming;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
|
||||
@@ -1193,7 +1208,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
// Disable Bluetooth to prevent interference during MQTT configuration
|
||||
disableBluetooth();
|
||||
moduleConfig.has_mqtt = true;
|
||||
moduleConfig.mqtt = c.payload_variant.mqtt;
|
||||
{
|
||||
char prevPass[sizeof(moduleConfig.mqtt.password)];
|
||||
memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass));
|
||||
moduleConfig.mqtt = c.payload_variant.mqtt;
|
||||
writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass);
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
case meshtastic_ModuleConfig_serial_tag:
|
||||
@@ -1279,6 +1299,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
moduleConfig.has_traffic_management = true;
|
||||
moduleConfig.traffic_management = c.payload_variant.traffic_management;
|
||||
break;
|
||||
case meshtastic_ModuleConfig_tak_tag:
|
||||
LOG_INFO("Set module config: TAK");
|
||||
moduleConfig.has_tak = true;
|
||||
moduleConfig.tak = c.payload_variant.tak;
|
||||
break;
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
case meshtastic_ModuleConfig_mesh_beacon_tag: {
|
||||
LOG_INFO("Set module config: MeshBeacon");
|
||||
@@ -1416,6 +1441,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1447,8 +1475,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
|
||||
LOG_INFO("Get config: Network");
|
||||
res.get_config_response.which_payload_variant = meshtastic_Config_network_tag;
|
||||
res.get_config_response.payload_variant.network = config.network;
|
||||
writeSecret(res.get_config_response.payload_variant.network.wifi_psk,
|
||||
sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk);
|
||||
if (req.from != 0)
|
||||
strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved,
|
||||
sizeof(res.get_config_response.payload_variant.network.wifi_psk));
|
||||
break;
|
||||
case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG:
|
||||
LOG_INFO("Get config: Display");
|
||||
@@ -1499,6 +1528,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1516,6 +1548,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
configName = "MQTT";
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
||||
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
if (req.from != 0)
|
||||
strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved,
|
||||
sizeof(res.get_module_config_response.payload_variant.mqtt.password));
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
|
||||
configName = "Serial";
|
||||
@@ -1587,6 +1622,11 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
|
||||
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG:
|
||||
configName = "TAK";
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
|
||||
res.get_module_config_response.payload_variant.tak = moduleConfig.tak;
|
||||
break;
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG:
|
||||
configName = "MeshBeacon";
|
||||
@@ -1608,6 +1648,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1635,6 +1678,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
|
||||
}
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1654,6 +1700,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1729,6 +1778,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1743,6 +1795,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1755,6 +1810,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
|
||||
r.get_ui_config_response = uiconfig;
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1849,8 +1907,14 @@ void AdminModule::setPassKey(meshtastic_AdminMessage *res)
|
||||
if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) {
|
||||
// Session passkey authenticates admin replies, so it must be unpredictable: prefer the
|
||||
// hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists.
|
||||
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
|
||||
CryptRNG.rand(session_passkey, sizeof(session_passkey));
|
||||
// Hold cryptLock like the signing path does: this runs on the admin receive path, which on
|
||||
// nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while
|
||||
// the CryptRNG state is shared with signing.
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
|
||||
CryptRNG.rand(session_passkey, sizeof(session_passkey));
|
||||
}
|
||||
session_time = millis();
|
||||
sessionPasskeyValid = true;
|
||||
}
|
||||
@@ -1931,7 +1995,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
||||
if (!responseVariant)
|
||||
return; // not a getter whose response we can pair
|
||||
|
||||
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
|
||||
// Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it.
|
||||
// p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode
|
||||
// sets it, on RX), so reading it here pinned nothing and left `from` as the sole check.
|
||||
bool keyValid = false;
|
||||
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey);
|
||||
keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey);
|
||||
#endif
|
||||
|
||||
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
|
||||
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
|
||||
@@ -1950,6 +2022,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
||||
}
|
||||
|
||||
slot->to = p.to;
|
||||
slot->requestId = p.id;
|
||||
slot->sentAtMs = millis();
|
||||
slot->expectedResponse = responseVariant;
|
||||
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
|
||||
@@ -1957,7 +2030,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
|
||||
: 0;
|
||||
slot->keyValid = keyValid;
|
||||
if (keyValid)
|
||||
memcpy(slot->key, p.public_key.bytes, 32);
|
||||
memcpy(slot->key, destKey.bytes, 32);
|
||||
else
|
||||
memset(slot->key, 0, 32);
|
||||
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
|
||||
@@ -1970,6 +2043,11 @@ bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t
|
||||
for (auto &o : outstandingAdminRequests) {
|
||||
if (o.to != mp.from || o.expectedResponse != responseVariant)
|
||||
continue;
|
||||
// mp.from is unauthenticated, so also require the response to echo our request's packet id
|
||||
// (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is
|
||||
// no token at all - an omitted request_id decodes to 0 - so such a slot never matches.
|
||||
if (o.requestId == 0 || mp.decoded.request_id != o.requestId)
|
||||
continue;
|
||||
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
|
||||
o.to = 0; // lapsed; free the slot and keep looking for another live match
|
||||
continue;
|
||||
|
||||
@@ -90,10 +90,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
|
||||
struct OutstandingAdminRequest {
|
||||
NodeNum to; // 0 = free slot
|
||||
uint32_t requestId; // our request's packet id; the response must echo it as request_id
|
||||
uint32_t sentAtMs; // millis() when this request went out
|
||||
pb_size_t expectedResponse; // the one response variant this request authorizes
|
||||
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
|
||||
uint8_t key[32]; // pinned destination key when the request went out over PKC
|
||||
uint8_t key[32]; // pinned destination key when the request goes out over PKC
|
||||
bool keyValid;
|
||||
};
|
||||
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
|
||||
|
||||
@@ -179,7 +179,7 @@ int CannedMessageModule::splitConfiguredMessages()
|
||||
while (i < upTo) {
|
||||
if (this->messageBuffer[i] == '|') {
|
||||
this->messageBuffer[i] = '\0'; // End previous message
|
||||
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT)
|
||||
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT - 1)
|
||||
break;
|
||||
tempMessages[tempCount++] = (this->messageBuffer + i + 1);
|
||||
}
|
||||
@@ -1022,6 +1022,8 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
lastDestSet = true;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->channel = channel;
|
||||
p->want_ack = true;
|
||||
@@ -1054,7 +1056,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size++] = 7;
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = '\0';
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
void resetSearch();
|
||||
void updateDestinationSelectionList();
|
||||
void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
bool isFreeTextActive() const { return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT; }
|
||||
String drawWithCursor(String text, int cursor);
|
||||
|
||||
// === Emote Picker ===
|
||||
|
||||
@@ -131,10 +131,14 @@ void DetectionSensorModule::sendDetectionMessage()
|
||||
char *message = new char[40];
|
||||
sprintf(message, "%s detected", moduleConfig.detection_sensor.name);
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p) {
|
||||
delete[] message;
|
||||
return;
|
||||
}
|
||||
p->want_ack = false;
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
|
||||
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Bell character
|
||||
p->decoded.payload.size++;
|
||||
@@ -153,6 +157,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state)
|
||||
char *message = new char[40];
|
||||
sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state);
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p) {
|
||||
delete[] message;
|
||||
return;
|
||||
}
|
||||
p->want_ack = false;
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
@@ -68,6 +68,8 @@ meshtastic_MeshPacket *DropzoneModule::sendConditions()
|
||||
// the dropzone is open
|
||||
auto dropzoneStatus = analogRead(A1) < 100 ? "OPEN" : "CLOSED";
|
||||
auto reply = allocDataPacket();
|
||||
if (!reply)
|
||||
return nullptr;
|
||||
|
||||
auto node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (sensor.hasSensor()) {
|
||||
|
||||
@@ -6,12 +6,19 @@
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/draw/MenuHandler.h"
|
||||
#include "main.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <RNG.h>
|
||||
#include <SHA256.h>
|
||||
|
||||
#define KEY_VERIFICATION_TIMEOUT_SECS 60
|
||||
// Hard cap on one session, independent of the refreshable idle timeout above.
|
||||
#define KEY_VERIFICATION_MAX_SESSION_MS (3 * 60 * 1000UL)
|
||||
// Minimum spacing between remote-initiated sessions.
|
||||
#define KEY_VERIFICATION_REMOTE_COOLDOWN_MS (60 * 1000UL)
|
||||
|
||||
KeyVerificationModule *keyVerificationModule;
|
||||
|
||||
namespace
|
||||
@@ -64,7 +71,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
|
||||
|
||||
bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r)
|
||||
{
|
||||
updateState();
|
||||
// No refresh here: this runs before the sender is checked, so any node could hold the session open.
|
||||
updateState(false);
|
||||
// Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in
|
||||
// the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below.
|
||||
if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply()
|
||||
@@ -98,6 +106,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
LOG_INFO("Received hash2");
|
||||
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
|
||||
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
|
||||
return true;
|
||||
|
||||
@@ -134,6 +143,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
|
||||
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER;
|
||||
return true;
|
||||
}
|
||||
@@ -151,8 +161,16 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
|
||||
return false;
|
||||
}
|
||||
updateState(true);
|
||||
currentNonce = random();
|
||||
// The nonce binds the handshake, so draw it from the hardware RNG (falling back to the CSPRNG)
|
||||
// under cryptLock, as allocReply does for the security number. random() is both predictable and
|
||||
// only 32 bits wide, leaving half of this nonce zero.
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!HardwareRNG::fill((uint8_t *)¤tNonce, sizeof(currentNonce)))
|
||||
CryptRNG.rand((uint8_t *)¤tNonce, sizeof(currentNonce));
|
||||
}
|
||||
currentNonceTimestamp = getTime();
|
||||
sessionStartedMs = millis();
|
||||
currentRemoteNode = remoteNode;
|
||||
meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero;
|
||||
KeyVerification.nonce = currentNonce;
|
||||
@@ -162,6 +180,8 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
|
||||
KeyVerification.hash1.size = 32;
|
||||
memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32);
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = remoteNode;
|
||||
p->channel = 0;
|
||||
// Only request PKI when we already hold the destination's key. Otherwise this first message goes out
|
||||
@@ -181,10 +201,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
|
||||
SHA256 hash;
|
||||
NodeNum ourNodeNum = nodeDB->getNodeNum();
|
||||
updateState();
|
||||
if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period
|
||||
if (currentState != KEY_VERIFICATION_IDLE) {
|
||||
LOG_WARN("Key Verification requested, but already in a request");
|
||||
ignoreRequest = true; // do not let the busy path emit a NAK back to the requester
|
||||
return nullptr;
|
||||
}
|
||||
// Opening a session raises a banner and locks the only slot, before the peer has authenticated.
|
||||
if (lastRemoteSessionMs != 0 && Throttle::isWithinTimespanMs(lastRemoteSessionMs, KEY_VERIFICATION_REMOTE_COOLDOWN_MS)) {
|
||||
LOG_WARN("Key Verification requested, but within cooldown");
|
||||
ignoreRequest = true;
|
||||
return nullptr;
|
||||
}
|
||||
sessionStartedMs = millis();
|
||||
sessionFromRemote = true;
|
||||
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1;
|
||||
|
||||
auto req = *currentRequest;
|
||||
@@ -250,6 +279,12 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
|
||||
memcpy(response.hash2.bytes, hash2, 32);
|
||||
|
||||
responsePacket = allocDataProtobuf(response);
|
||||
if (!responsePacket) {
|
||||
LOG_WARN("Key Verification response allocation failed");
|
||||
ignoreRequest = true;
|
||||
resetToIdle();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes
|
||||
// out channel-encrypted so the requester (who lacks our key) can decode it and read hash1.
|
||||
@@ -318,6 +353,8 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
||||
KeyVerification.hash1.size = 32;
|
||||
memcpy(KeyVerification.hash1.bytes, hash1, 32);
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
|
||||
if (!p)
|
||||
return;
|
||||
p->to = currentRemoteNode;
|
||||
p->channel = 0;
|
||||
p->pki_encrypted = true;
|
||||
@@ -346,11 +383,14 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
||||
void KeyVerificationModule::updateState(bool resetTimer)
|
||||
{
|
||||
if (currentState != KEY_VERIFICATION_IDLE) {
|
||||
// check for the 60 second timeout
|
||||
if (currentNonceTimestamp < getTime() - 60) {
|
||||
uint32_t now = getTime();
|
||||
// Absolute cap first: it is millis() based, so it bounds the session even when the RTC is unset.
|
||||
if (!Throttle::isWithinTimespanMs(sessionStartedMs, KEY_VERIFICATION_MAX_SESSION_MS)) {
|
||||
resetToIdle();
|
||||
} else if (now - currentNonceTimestamp >= KEY_VERIFICATION_TIMEOUT_SECS) {
|
||||
resetToIdle();
|
||||
} else if (resetTimer) {
|
||||
currentNonceTimestamp = getTime();
|
||||
currentNonceTimestamp = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,8 +399,12 @@ void KeyVerificationModule::resetToIdle()
|
||||
{
|
||||
memset(hash1, 0, 32);
|
||||
memset(hash2, 0, 32);
|
||||
if (sessionFromRemote)
|
||||
lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened
|
||||
sessionFromRemote = false;
|
||||
currentNonce = 0;
|
||||
currentNonceTimestamp = 0;
|
||||
sessionStartedMs = 0;
|
||||
currentSecurityNumber = 0;
|
||||
currentRemoteNode = 0;
|
||||
currentState = KEY_VERIFICATION_IDLE;
|
||||
@@ -383,6 +427,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode()
|
||||
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
|
||||
node->public_key = pending;
|
||||
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||
// Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot
|
||||
// store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just
|
||||
// confirmed possession of exactly this key - the strongest provenance that cache can carry.
|
||||
if (node->public_key.size == 32)
|
||||
nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified);
|
||||
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
|
||||
if (nodeInfoModule)
|
||||
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);
|
||||
|
||||
@@ -84,6 +84,12 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
|
||||
private:
|
||||
uint64_t currentNonce = 0;
|
||||
uint32_t currentNonceTimestamp = 0;
|
||||
// millis() the session opened, never refreshed, so a peer cannot hold the slot open indefinitely.
|
||||
uint32_t sessionStartedMs = 0;
|
||||
// millis() a remote-initiated session last ended. Spacing sessions bounds the slot DoS and the
|
||||
// banner spam; stamped at the end rather than the start so the cooldown is a real gap.
|
||||
uint32_t lastRemoteSessionMs = 0;
|
||||
bool sessionFromRemote = false;
|
||||
NodeNum currentRemoteNode = 0;
|
||||
uint32_t currentSecurityNumber = 0;
|
||||
KeyVerificationState currentState = KEY_VERIFICATION_IDLE;
|
||||
|
||||
@@ -110,6 +110,8 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies)
|
||||
// only send neighbours if we have some to send
|
||||
if (neighborInfo.neighbors_count > 0) {
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo);
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -234,4 +234,4 @@ int32_t NodeInfoModule::runOnce()
|
||||
sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies)
|
||||
}
|
||||
return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,8 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli()
|
||||
{
|
||||
LOG_INFO("Send TAK V2 PLI packet");
|
||||
meshtastic_MeshPacket *mp = allocDataPacket();
|
||||
if (!mp)
|
||||
return nullptr;
|
||||
mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2;
|
||||
|
||||
meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero;
|
||||
@@ -572,6 +574,8 @@ int32_t PositionModule::runOnce()
|
||||
void PositionModule::sendLostAndFoundText()
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
char message[128];
|
||||
int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7),
|
||||
|
||||
@@ -8,15 +8,16 @@
|
||||
* The RangeTestModule class is an OSThread that runs the module.
|
||||
* The RangeTestModuleRadio class handles sending and receiving packets.
|
||||
*/
|
||||
#include "RangeTestModule.h"
|
||||
#include "configuration.h"
|
||||
#if !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS
|
||||
#include "FSCommon.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RangeTestModule.h"
|
||||
#include "Router.h"
|
||||
#include "SPILock.h"
|
||||
#include "airtime.h"
|
||||
#include "configuration.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "gps/RTC.h"
|
||||
#include <Arduino.h>
|
||||
@@ -114,6 +115,8 @@ int32_t RangeTestModule::runOnce()
|
||||
void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
p->hop_limit = 0;
|
||||
@@ -351,3 +354,5 @@ bool RangeTestModuleRadio::removeFile()
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif // !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS
|
||||
|
||||
@@ -168,6 +168,8 @@ void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text)
|
||||
if (!text)
|
||||
return;
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = rx.from;
|
||||
p->channel = rx.channel;
|
||||
p->want_ack = false;
|
||||
|
||||
@@ -16,7 +16,9 @@ meshtastic_MeshPacket *ReplyModule::allocReply()
|
||||
#endif
|
||||
|
||||
const char *replyStr = "Message Received";
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
if (!reply)
|
||||
return nullptr;
|
||||
reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply
|
||||
memcpy(reply->decoded.payload.bytes, replyStr, reply->decoded.payload.size);
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI
|
||||
bool ackWantsAck)
|
||||
{
|
||||
auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit);
|
||||
if (!p)
|
||||
return;
|
||||
|
||||
// Allow the caller to set want_ack on this ACK packet if it's important that the ACK be delivered reliably
|
||||
p->want_ack = ackWantsAck;
|
||||
|
||||
@@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
|
||||
return true;
|
||||
}
|
||||
|
||||
SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio")
|
||||
SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP)
|
||||
{
|
||||
switch (moduleConfig.serial.mode) {
|
||||
case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG:
|
||||
@@ -314,6 +314,8 @@ int32_t SerialModule::runOnce()
|
||||
void SerialModule::sendTelemetry(meshtastic_Telemetry m)
|
||||
{
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return;
|
||||
p->decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Telemetry_msg, &m);
|
||||
@@ -328,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m)
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a new mesh packet for use as a reply to a received packet.
|
||||
*
|
||||
* @return A pointer to the newly allocated mesh packet.
|
||||
*/
|
||||
meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
{
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a payload to a specified destination node.
|
||||
*
|
||||
@@ -349,7 +339,9 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL;
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
if (ch != NULL) {
|
||||
p->channel = ch->index;
|
||||
|
||||
@@ -40,7 +40,7 @@ extern SerialModule *serialModule;
|
||||
* Radio interface for SerialModule
|
||||
*
|
||||
*/
|
||||
class SerialModuleRadio : public MeshModule
|
||||
class SerialModuleRadio : public SinglePortModule
|
||||
{
|
||||
uint32_t lastRxID = 0;
|
||||
char outbuf[90] = "";
|
||||
@@ -54,29 +54,14 @@ class SerialModuleRadio : public MeshModule
|
||||
void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false);
|
||||
|
||||
protected:
|
||||
virtual meshtastic_MeshPacket *allocReply() override;
|
||||
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for
|
||||
it
|
||||
*/
|
||||
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
|
||||
|
||||
meshtastic_MeshPacket *allocDataPacket()
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
p->decoded.portnum = ourPortNum;
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
extern SerialModuleRadio *serialModuleRadio;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -15,6 +15,8 @@ int32_t StatusMessageModule::runOnce()
|
||||
strncpy(ourStatus.status, moduleConfig.statusmessage.node_status, sizeof(ourStatus.status));
|
||||
ourStatus.status[sizeof(ourStatus.status) - 1] = '\0'; // ensure null termination
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return 1000 * 12 * 60 * 60;
|
||||
p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes),
|
||||
meshtastic_StatusMessage_fields, &ourStatus);
|
||||
p->to = NODENUM_BROADCAST;
|
||||
|
||||
@@ -248,6 +248,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
|
||||
(this->packetHistory[i].to == NODENUM_BROADCAST || this->packetHistory[i].to == dest)) {
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to`
|
||||
p->from = this->packetHistory[i].from;
|
||||
@@ -304,6 +306,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
|
||||
void StoreForwardModule::sendMessage(NodeNum dest, const meshtastic_StoreAndForward &payload)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(payload);
|
||||
if (!p)
|
||||
return;
|
||||
|
||||
p->to = dest;
|
||||
|
||||
@@ -340,6 +344,8 @@ void StoreForwardModule::sendMessage(NodeNum dest, meshtastic_StoreAndForward_Re
|
||||
void StoreForwardModule::sendErrorTextMessage(NodeNum dest, bool want_response)
|
||||
{
|
||||
meshtastic_MeshPacket *pr = allocDataPacket();
|
||||
if (!pr)
|
||||
return;
|
||||
pr->to = dest;
|
||||
pr->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
pr->want_ack = false;
|
||||
|
||||
@@ -464,35 +464,39 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Sending packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Sending packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Sending packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Sending packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,8 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry()
|
||||
void DeviceTelemetryModule::sendLocalStatsToPhone()
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(getLocalStatsTelemetry());
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
@@ -191,6 +193,8 @@ bool DeviceTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
|
||||
DEBUG_HEAP_AFTER("DeviceTelemetryModule::sendTelemetry", p);
|
||||
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -653,34 +653,38 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
m.variant.environment_metrics.soil_moisture);
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,23 +248,27 @@ bool HealthTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
sensor_read_error_count = 0;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ bool HostMetricsModule::sendMetrics()
|
||||
// telemetry.variant.host_metrics.has_user_string ? telemetry.variant.host_metrics.user_string : "");
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -275,23 +275,27 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
sensor_read_error_count = 0;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
TraceRouteModule *traceRouteModule;
|
||||
|
||||
@@ -305,6 +305,15 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic
|
||||
}
|
||||
uint8_t nextHopByte = nodeDB->getLastByteOfNodeNum(nextHop);
|
||||
|
||||
// The route array is unauthenticated payload, so only learn from it when the node it names as our
|
||||
// next hop is the one that actually relayed this packet to us. Otherwise a forged response could
|
||||
// point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot
|
||||
// corroborate an RF route either.
|
||||
if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) {
|
||||
LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node);
|
||||
return;
|
||||
}
|
||||
|
||||
// For the rest of the nodes in the route, set their next-hop
|
||||
// Note: if we are the last in the route, this loop will not run
|
||||
for (int8_t i = nextHopIndex; i < r->route_count; i++) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,25 +8,32 @@
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
|
||||
/**
|
||||
* TrafficManagementModule - Packet inspection and traffic shaping for mesh networks.
|
||||
*
|
||||
* This module provides:
|
||||
* - Position deduplication (drop redundant position broadcasts)
|
||||
* - Per-node rate limiting (throttle chatty nodes)
|
||||
* - Unknown packet filtering (drop undecoded packets from repeat offenders)
|
||||
* - NodeInfo direct response (answer queries from cache to reduce mesh chatter)
|
||||
* - Local-only telemetry/position (exhaust hop_limit for local broadcasts)
|
||||
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
|
||||
*
|
||||
* Memory Optimization:
|
||||
* Uses one flat unified cache (plain array, linear scan) shared by all
|
||||
* per-node features instead of separate per-feature caches. Timestamps are
|
||||
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
|
||||
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
|
||||
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
|
||||
* negligible next to packet processing.
|
||||
*/
|
||||
// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose
|
||||
// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities.
|
||||
// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded.
|
||||
#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED
|
||||
#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1
|
||||
#endif
|
||||
|
||||
// Effective gate: only meaningful when PKI is compiled in.
|
||||
#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1
|
||||
#else
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0
|
||||
#endif
|
||||
|
||||
// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big
|
||||
// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths
|
||||
// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()).
|
||||
#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING))
|
||||
#define TMM_HAS_NODEINFO_CACHE 1
|
||||
#else
|
||||
#define TMM_HAS_NODEINFO_CACHE 0
|
||||
#endif
|
||||
|
||||
/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet
|
||||
/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte
|
||||
/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview.
|
||||
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
@@ -37,41 +44,66 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
TrafficManagementModule(const TrafficManagementModule &) = delete;
|
||||
TrafficManagementModule &operator=(const TrafficManagementModule &) = delete;
|
||||
|
||||
/// Snapshot of the module's counters (thread-safe).
|
||||
meshtastic_TrafficManagementStats getStats() const;
|
||||
/// Zero all counters (thread-safe).
|
||||
void resetStats();
|
||||
/// Placeholder for the removed router_preserve_hops stat.
|
||||
void recordRouterHopPreserved();
|
||||
|
||||
// Next-hop overflow cache (routing hint).
|
||||
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
|
||||
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
|
||||
// byte must come from a bidirectionally-verified relay, not one-way inference.
|
||||
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
|
||||
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
|
||||
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
|
||||
/// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's
|
||||
/// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay.
|
||||
void setNextHop(NodeNum dest, uint8_t nextHopByte);
|
||||
/// Cached next-hop byte for `dest`, 0 if unknown.
|
||||
uint8_t getNextHopHint(NodeNum dest);
|
||||
/// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route).
|
||||
void clearNextHop(NodeNum dest);
|
||||
|
||||
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
|
||||
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
|
||||
// nodeDB is populated (lazily on first maintenance pass).
|
||||
// @return true if it actually ran (prereqs met / nothing to do); false if
|
||||
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
|
||||
/// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive
|
||||
/// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't
|
||||
/// ready and the caller should retry on a later pass.
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/**
|
||||
* Check if this packet should have its hops exhausted.
|
||||
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
|
||||
* router_preserve_hops or favorite node logic.
|
||||
*/
|
||||
/// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss.
|
||||
/// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether
|
||||
/// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe.
|
||||
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const;
|
||||
|
||||
/// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's
|
||||
/// name - the warm tier keeps keys but not names). False on miss or key-only records.
|
||||
/// `signerProven` (optional) reports the cached key's provenance. Thread-safe.
|
||||
bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const;
|
||||
|
||||
/// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately
|
||||
/// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless
|
||||
/// commit keeps a TOFU key this cache already holds; never touches the observation stamp.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown);
|
||||
|
||||
/// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key
|
||||
/// verification). A changed key resets provenance; pass proven=true only when the commit
|
||||
/// itself established possession. Never touches the observation stamp. Thread-safe.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven);
|
||||
|
||||
/// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup
|
||||
/// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node;
|
||||
/// passive eviction is unaffected. Thread-safe.
|
||||
void purgeNode(NodeNum node);
|
||||
/// Clear both cache tables outright (resetNodes / factory reset). Thread-safe.
|
||||
void purgeAll();
|
||||
|
||||
/// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of
|
||||
/// router_preserve_hops or favorite-node logic (set by alterReceived()).
|
||||
bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const
|
||||
{
|
||||
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
|
||||
}
|
||||
|
||||
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
|
||||
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
|
||||
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
|
||||
// ignored in production (clockMs() returns millis()).
|
||||
// Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across
|
||||
// ticks (mirrors HopScalingModule); production reads millis().
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
/// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING).
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t clockMs() { return s_testNowMs; }
|
||||
#else
|
||||
@@ -79,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response.
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
/// Promiscuous: this module inspects every packet.
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
|
||||
/// Mutate relayed packets in place (position precision clamp).
|
||||
void alterReceived(meshtastic_MeshPacket &mp) override;
|
||||
/// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB.
|
||||
int32_t runOnce() override;
|
||||
// Protected so test shims can flush per-node traffic state.
|
||||
/// Clear all per-node traffic state (protected for test shims).
|
||||
void flushCache();
|
||||
// Introspection for tests: the cached device role for a node, or -1 if the node has
|
||||
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
|
||||
/// Test introspection: the cached role for `node`, or -1 when it has no entry
|
||||
/// (distinguishes "not tracked" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
/// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests
|
||||
/// can skip a full XEdDSA verification. No-op if absent.
|
||||
void markKeySignerProvenForTest(NodeNum node);
|
||||
|
||||
/// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in
|
||||
/// builds where the cache is compiled in. No-op when already absent.
|
||||
void dropNodeInfoCacheForTest();
|
||||
|
||||
/// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved,
|
||||
/// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven.
|
||||
int peekNodeInfoFlagsForTest(NodeNum node);
|
||||
|
||||
/// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can
|
||||
/// fill the cache exactly and force the tiered-LRU eviction paths.
|
||||
static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; }
|
||||
|
||||
private:
|
||||
// =========================================================================
|
||||
// Unified Cache Entry (10 bytes) - Same for ALL platforms
|
||||
// =========================================================================
|
||||
//
|
||||
// Layout:
|
||||
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
|
||||
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
|
||||
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
|
||||
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
|
||||
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
|
||||
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
|
||||
//
|
||||
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
|
||||
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
|
||||
// hot store and warm store, for nodes evicted from both. Read/written via
|
||||
// resolveSenderRole(). Max encodable value is 15.
|
||||
//
|
||||
// Presence sentinels (no epoch, no +1 offset needed):
|
||||
// pos active: pos_fingerprint != 0
|
||||
// rate active: getRateCount() != 0 (low 6 bits only)
|
||||
// unknown active: getUnknownCount() != 0 (low 6 bits only)
|
||||
//
|
||||
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
|
||||
// No TTL - keeps the slot alive across maintenance sweeps.
|
||||
//
|
||||
// 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with
|
||||
// non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count
|
||||
// bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md.
|
||||
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
|
||||
#warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
|
||||
#endif
|
||||
@@ -128,80 +157,72 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
uint8_t rate_unknown_time;
|
||||
uint8_t next_hop;
|
||||
|
||||
/// Packets seen in the current rate window (low 6 bits).
|
||||
uint8_t getRateCount() const { return rate_count & 0x3F; }
|
||||
/// Set the rate-window count, preserving the role bits.
|
||||
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Unknown packets seen in the current window (low 6 bits).
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
/// Set the unknown-window count, preserving the role bits.
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Cached 4-bit device role, reassembled from the two count bytes' top bits.
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
/// Store a 4-bit device role across the two count bytes' top bits.
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
/// Rate-window tick nibble.
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
/// Unknown-window tick nibble.
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
/// Set the rate-window tick nibble.
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
/// Set the unknown-window tick nibble.
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
|
||||
|
||||
// =========================================================================
|
||||
// Flat unified cache
|
||||
// =========================================================================
|
||||
//
|
||||
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
|
||||
// most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a
|
||||
// hash table. Insertion on a full cache evicts the stalest entry,
|
||||
// preferring entries without a next_hop hint (those are the long-tail
|
||||
// routing state this cache exists to keep).
|
||||
//
|
||||
/// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion
|
||||
/// on a full cache evicts the stalest entry, preferring ones without a next_hop hint.
|
||||
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
|
||||
|
||||
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
|
||||
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
|
||||
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
|
||||
// NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array,
|
||||
// linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is
|
||||
// low-rate, so full scans are fine.
|
||||
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
|
||||
/// NodeInfo cache capacity.
|
||||
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
|
||||
|
||||
// =========================================================================
|
||||
// Free-Running Tick Counters
|
||||
// =========================================================================
|
||||
//
|
||||
// Timestamps are stored as free-running modular tick counters derived from
|
||||
// millis(). No epoch anchor needed: modular subtraction gives correct age
|
||||
// as long as the true age stays below the counter period.
|
||||
//
|
||||
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
|
||||
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
|
||||
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
|
||||
//
|
||||
// Presence sentinels (no +1 offset needed; count fields serve as guards):
|
||||
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
|
||||
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
|
||||
// unknown active: getUnknownCount() != 0
|
||||
//
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
|
||||
// Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct
|
||||
// age while true age stays below the counter period. Presence is carried by non-zero
|
||||
// sentinels (unified cache) or explicit validity bits (NodeInfo cache).
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period)
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period)
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period)
|
||||
|
||||
/// Current position-clock tick (6 min/tick).
|
||||
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
/// Current rate-clock tick nibble (5 min/tick).
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
/// Current unknown-clock tick nibble (1 min/tick).
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
//
|
||||
// Computes 8-bit fingerprint from truncated lat/lon coordinates.
|
||||
// Extracts lower 4 significant bits from each coordinate.
|
||||
//
|
||||
// fingerprint = (lat_low4 << 4) | lon_low4
|
||||
//
|
||||
// Unlike a hash, adjacent grid cells have sequential fingerprints,
|
||||
// so nearby positions never collide. Collisions only occur for
|
||||
// positions 16+ grid cells apart in both dimensions.
|
||||
//
|
||||
// Guards: If precision < 4 bits, uses min(precision, 4) bits.
|
||||
//
|
||||
|
||||
// NodeInfo observation tick (same idiom). The 60 s sweep clears the presence bit once the serve
|
||||
// window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling
|
||||
// no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use
|
||||
// wrap-safe uint32 ms compares and so need no tick clock or sweep.)
|
||||
static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period)
|
||||
static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window
|
||||
|
||||
/// Current NodeInfo observation tick (3 min/tick).
|
||||
static uint8_t currentObsTick() { return static_cast<uint8_t>(clockMs() / kNodeInfoObsTickMs); }
|
||||
static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL,
|
||||
"cache serve window must equal the fallback path's 6 h");
|
||||
|
||||
/// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so
|
||||
/// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions).
|
||||
static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision);
|
||||
|
||||
// =========================================================================
|
||||
@@ -213,42 +234,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
struct NodeInfoPayloadEntry {
|
||||
// Node identifier associated with this payload slot.
|
||||
// 0 means the slot is currently unused.
|
||||
// Node identifier for this slot; 0 means unused.
|
||||
NodeNum node;
|
||||
|
||||
// Cached NODEINFO_APP payload body. This is separate from NodeDB and is only
|
||||
// used by the PSRAM-backed direct-response path in this module.
|
||||
// Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed
|
||||
// direct-response path and the last-resort pubkey pool.
|
||||
meshtastic_User user;
|
||||
|
||||
// Extra response metadata captured from the latest observed NODEINFO_APP
|
||||
// packet for this node. shouldRespondToNodeInfo() uses this metadata when
|
||||
// building spoofed replies for requesting clients.
|
||||
|
||||
// Last local uptime tick (millis) when this entry was refreshed.
|
||||
uint32_t lastObservedMs;
|
||||
|
||||
// Last RTC/packet timestamp (seconds) observed for this NodeInfo frame.
|
||||
// If unavailable in packet, remains 0.
|
||||
uint32_t lastObservedRxTime;
|
||||
// Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the
|
||||
// replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed
|
||||
// reply is only ever backed by genuine recent observation. Validity: hasObserved.
|
||||
uint8_t obsTick;
|
||||
|
||||
// Channel where we most recently heard this node's NodeInfo.
|
||||
uint8_t sourceChannel;
|
||||
|
||||
// Cached decoded bitfield metadata from the source packet.
|
||||
// We preserve non-OK_TO_MQTT bits in direct replies when available.
|
||||
bool hasDecodedBitfield;
|
||||
// Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved
|
||||
// in direct replies). Validity: hasDecodedBitfield.
|
||||
uint8_t decodedBitfield;
|
||||
};
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
|
||||
// 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather
|
||||
// than new bytes - the array is 2000 entries).
|
||||
|
||||
// The source packet carried a decoded bitfield (so decodedBitfield is meaningful).
|
||||
uint8_t hasDecodedBitfield : 1;
|
||||
|
||||
// Key provenance: set once an XEdDSA signature was verified for user.public_key
|
||||
// (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot;
|
||||
// the key-pin checks forbid the key changing underneath it. TOFU keys start at 0.
|
||||
uint8_t keySignerProven : 1;
|
||||
|
||||
// obsTick is valid: a NODEINFO frame was actually heard within the observation clock's
|
||||
// horizon. Cleared by the sweep once the serve window passes (saturation).
|
||||
uint8_t hasObserved : 1;
|
||||
|
||||
// `user` carries a real User payload (from an observed frame or hot-store seed) rather
|
||||
// than a key-only warm-tier record. copyUser()/name-rehydration require it.
|
||||
uint8_t hasFullUser : 1;
|
||||
|
||||
// Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass
|
||||
// (write-through hooks set it immediately on commit; purgeNode clears immediately on
|
||||
// removal; a passive NodeDB eviction may lag up to an hour). Member entries are
|
||||
// stickiest under LRU; the bit is the keep-alive (no TTL).
|
||||
uint8_t isMember : 1;
|
||||
};
|
||||
// No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so
|
||||
// any fixed byte count would fail the build on some boards.
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests)
|
||||
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
meshtastic_TrafficManagementStats stats;
|
||||
|
||||
// Flag set during alterReceived() when packet should be exhausted.
|
||||
// Checked by perhapsRebroadcast() to force hop_limit = 0 only for the
|
||||
// matching packet key (from + id). Reset at start of handleReceived().
|
||||
// Set during alterReceived() when the packet's hops should be exhausted; checked by
|
||||
// perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived().
|
||||
bool exhaustRequested = false;
|
||||
NodeNum exhaustRequestedFrom = 0;
|
||||
PacketId exhaustRequestedId = 0;
|
||||
@@ -256,48 +295,97 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
|
||||
bool nextHopPreloaded = false;
|
||||
|
||||
// Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The
|
||||
// write-through hooks give immediacy; this periodic repair self-heals anything they miss.
|
||||
static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h)
|
||||
bool nodeInfoSeeded = false;
|
||||
uint8_t sweepsSinceNodeInfoReconcile = 0;
|
||||
|
||||
// =========================================================================
|
||||
// Cache Operations
|
||||
// =========================================================================
|
||||
|
||||
// Find or create entry for node (linear scan; stalest-first eviction when full)
|
||||
/// Find or create the unified-cache entry for `node` (stalest-first eviction when full).
|
||||
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
|
||||
|
||||
// Find existing entry (no creation)
|
||||
/// Find an existing unified-cache entry (no creation).
|
||||
UnifiedCacheEntry *findEntry(NodeNum node);
|
||||
|
||||
// Resolve a sender's advertised device role for the position hot path. The tier-3
|
||||
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
|
||||
// updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived
|
||||
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
|
||||
// store, via getNodeRole) to seed the cache, so a resident special-role node is
|
||||
// correct from its first position; after that the read is O(1) and survives the node
|
||||
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
|
||||
// (→ NodeDB scan only).
|
||||
/// Resolve a sender's device role for the position hot path. The tier-3 cache is
|
||||
/// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1)
|
||||
/// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock.
|
||||
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
|
||||
|
||||
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
|
||||
// tracked (no entry creation). Takes cacheLock.
|
||||
/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
/// NodeDB's role). Updates only nodes already tracked. Takes cacheLock.
|
||||
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
|
||||
/// Find an existing NodeInfo cache entry (no creation).
|
||||
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
|
||||
/// Mutable variant of findNodeInfoEntry().
|
||||
NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node)
|
||||
{
|
||||
return const_cast<NodeInfoPayloadEntry *>(findNodeInfoEntry(node));
|
||||
}
|
||||
/// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full.
|
||||
/// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding
|
||||
/// pass never churns one NodeDB-tier node out for another; the packet path may).
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false);
|
||||
/// Number of occupied NodeInfo cache slots. Caller must hold cacheLock.
|
||||
uint16_t countNodeInfoEntriesLocked() const;
|
||||
|
||||
/// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety
|
||||
/// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone
|
||||
/// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety".
|
||||
void maintainNodeInfoCacheLocked();
|
||||
|
||||
/// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets
|
||||
/// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB
|
||||
/// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)".
|
||||
void reconcileNodeInfoFromNodeDBLocked();
|
||||
/// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply).
|
||||
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// =========================================================================
|
||||
// Traffic Management Logic
|
||||
// =========================================================================
|
||||
|
||||
/// True when this position broadcast duplicates the sender's last one within the dedup window.
|
||||
bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs);
|
||||
/// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request.
|
||||
bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse);
|
||||
|
||||
// Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds
|
||||
// (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and
|
||||
// PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses".
|
||||
static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL;
|
||||
static constexpr size_t kDirectResponseTrackedNodes = 8;
|
||||
struct DirectResponseThrottleEntry {
|
||||
NodeNum key; // requester or target node; 0 = unused slot
|
||||
uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node
|
||||
};
|
||||
DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {};
|
||||
DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {};
|
||||
uint32_t lastDirectResponseMs = 0;
|
||||
/// True (and records the send) when a spoofed direct reply to `requester` for `target` is within
|
||||
/// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it).
|
||||
bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs);
|
||||
/// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs`
|
||||
/// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass.
|
||||
static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs,
|
||||
uint32_t windowMs);
|
||||
/// True when the requestor is within the role-clamped hop limit for direct responses.
|
||||
bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const;
|
||||
/// True when `from` exceeded the configured packet budget for the current rate window.
|
||||
bool isRateLimited(NodeNum from, uint32_t nowMs);
|
||||
/// True when `p`'s sender exceeded the undecodable-packet threshold for the current window.
|
||||
bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs);
|
||||
|
||||
/// Log a traffic action (drop/respond/clamp) with port name and packet routing context.
|
||||
void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const;
|
||||
/// Increment a stats counter under cacheLock.
|
||||
void incrementStat(uint32_t *field);
|
||||
};
|
||||
|
||||
|
||||
@@ -258,6 +258,8 @@ bool AudioModule::shouldDraw()
|
||||
void AudioModule::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ bool PaxcounterModule::sendInfo(NodeNum dest)
|
||||
pl.uptime = millis() / 1000;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(pl);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -105,6 +105,8 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score)
|
||||
if (!initials || initials[0] == '\0')
|
||||
return;
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->channel = 0; // primary channel
|
||||
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
|
||||
// screen is defined in main.cpp
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
#endif
|
||||
|
||||
BMM150Sensor::BMM150Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
|
||||
|
||||
@@ -8,7 +8,7 @@ BMX160Sensor::BMX160Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
|
||||
// screen is defined in main.cpp
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
#endif
|
||||
|
||||
bool BMX160Sensor::init()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
|
||||
// screen is defined in main.cpp
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
#endif
|
||||
|
||||
// Flag when an interrupt has been detected
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
#endif
|
||||
|
||||
static constexpr float MMC5983MA_ZERO_FIELD = 131072.0f;
|
||||
|
||||
@@ -45,7 +45,7 @@ CompassAccelSample latestCompassAccelSample;
|
||||
} // namespace
|
||||
|
||||
// screen is defined in main.cpp
|
||||
extern graphics::Screen *screen;
|
||||
extern std::unique_ptr<graphics::Screen> screen;
|
||||
|
||||
MotionSensor::MotionSensor(ScanI2C::FoundDevice foundDevice)
|
||||
{
|
||||
|
||||
@@ -121,6 +121,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
// receives it when we get our own packet back. Then we'll stop our retransmissions.
|
||||
if (isFromUs(e.packet)) {
|
||||
auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index);
|
||||
if (!pAck)
|
||||
return;
|
||||
pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
|
||||
if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(pAck);
|
||||
@@ -141,6 +143,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
}
|
||||
|
||||
UniquePacketPoolPacket p = packetPool.allocUniqueZeroed();
|
||||
if (!p)
|
||||
return;
|
||||
p->from = e.packet->from;
|
||||
p->to = e.packet->to;
|
||||
p->id = e.packet->id;
|
||||
@@ -148,7 +152,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
p->hop_limit = e.packet->hop_limit;
|
||||
p->hop_start = e.packet->hop_start;
|
||||
p->want_ack = e.packet->want_ack;
|
||||
p->via_mqtt = true; // Mark that the packet was received via MQTT
|
||||
p->via_mqtt = true; // Mark that the packet was received via MQTT
|
||||
p->pki_encrypted = false; // Only local AES-CCM decryption may establish PKI authentication.
|
||||
p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
|
||||
p->which_payload_variant = e.packet->which_payload_variant;
|
||||
memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted)));
|
||||
@@ -173,12 +178,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
// impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path
|
||||
// (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared
|
||||
// CryptoEngine cache state, and MQTT ingress can run on a different task.
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!checkXeddsaReceivePolicy(p.get())) {
|
||||
LOG_INFO("Ignore decoded message failing XEdDSA policy");
|
||||
return;
|
||||
}
|
||||
if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) {
|
||||
LOG_INFO("Ignore decoded message failing XEdDSA policy");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -191,8 +193,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
// likely they discovered each other via a channel we have downlink enabled for
|
||||
if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx)))
|
||||
router->enqueueReceivedMessage(p.release());
|
||||
} else if (router &&
|
||||
perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key
|
||||
} else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT)
|
||||
router->enqueueReceivedMessage(p.release());
|
||||
}
|
||||
|
||||
@@ -335,7 +336,20 @@ void MQTT::mqttCallback(char *topic, byte *payload, unsigned int length)
|
||||
|
||||
void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg)
|
||||
{
|
||||
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
|
||||
// payload_variant is a union: on the text variant, data.size aliases the first bytes of the
|
||||
// string, so reading it unconditionally let a client name any length up to PB_SIZE_MAX.
|
||||
switch (msg.which_payload_variant) {
|
||||
case meshtastic_MqttClientProxyMessage_data_tag:
|
||||
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
|
||||
break;
|
||||
case meshtastic_MqttClientProxyMessage_text_tag:
|
||||
onReceive(msg.topic, (byte *)msg.payload_variant.text,
|
||||
strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text)));
|
||||
break;
|
||||
default:
|
||||
LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void MQTT::onReceive(char *topic, byte *payload, size_t length)
|
||||
|
||||
@@ -36,7 +36,7 @@ class TouchInkHUDBridge : public Observer<const InputEvent *>
|
||||
|
||||
// Check whether a system applet (e.g. menu) is currently handling input
|
||||
bool systemHandlingInput = false;
|
||||
for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) {
|
||||
for (const NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) {
|
||||
if (sa->handleInput) {
|
||||
systemHandlingInput = true;
|
||||
break;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "error.h"
|
||||
#include "main.h"
|
||||
#include "mesh/PhoneAPI.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#include "mesh/mesh-pb-constants.h"
|
||||
#include <bluefruit.h>
|
||||
#include <utility/bonding.h>
|
||||
@@ -466,17 +467,23 @@ bool NRF52Bluetooth::onUnwantedPairing(uint16_t conn_handle, uint8_t const passk
|
||||
// Disconnect any BLE connections
|
||||
void NRF52Bluetooth::disconnect()
|
||||
{
|
||||
static constexpr uint32_t DISCONNECT_TIMEOUT_MSEC = 1000;
|
||||
uint8_t connection_num = Bluefruit.connected();
|
||||
if (connection_num) {
|
||||
// Close all connections. We're only expecting one.
|
||||
for (uint8_t i = 0; i < connection_num; i++)
|
||||
Bluefruit.disconnect(i);
|
||||
|
||||
// Wait for disconnection
|
||||
while (Bluefruit.connected())
|
||||
yield();
|
||||
// Best-effort wait: on Bluefruit's BLE event task the DISCONNECTED event can't be processed
|
||||
// until this callback returns, so an unbounded wait would deadlock until the watchdog fires.
|
||||
uint32_t start = millis();
|
||||
while (Bluefruit.connected() && Throttle::isWithinTimespanMs(start, DISCONNECT_TIMEOUT_MSEC))
|
||||
delay(1);
|
||||
|
||||
LOG_INFO("Ended BLE connection");
|
||||
if (Bluefruit.connected())
|
||||
LOG_WARN("BLE disconnect unconfirmed after %ums, continuing shutdown", millis() - start);
|
||||
else
|
||||
LOG_INFO("Ended BLE connection");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,13 +323,19 @@ void SimRadio::startReceive(meshtastic_MeshPacket *p)
|
||||
return;
|
||||
}
|
||||
}
|
||||
isReceiving = true;
|
||||
receivingPacket = packetPool.allocCopy(*p);
|
||||
if (!receivingPacket) {
|
||||
return;
|
||||
}
|
||||
isReceiving = true;
|
||||
uint32_t airtimeMsec = getPacketTime(p, true);
|
||||
notifyLater(airtimeMsec, ISR_RX, false); // Model the time it is busy receiving
|
||||
#else
|
||||
isReceiving = true;
|
||||
receivingPacket = packetPool.allocCopy(*p);
|
||||
if (!receivingPacket) {
|
||||
return;
|
||||
}
|
||||
isReceiving = true;
|
||||
handleReceiveInterrupt(); // Simulate receiving the packet immediately
|
||||
startTransmitTimer();
|
||||
#endif
|
||||
|
||||
@@ -404,6 +404,8 @@ A well-structured test suite follows this pattern:
|
||||
| `test_position_precision` | Position precision helpers |
|
||||
| `test_radio` | Radio interface |
|
||||
| `test_serial` | Serial communication |
|
||||
| `test_module_config` | AdminModule module config |
|
||||
| `test_tak_config` | TAK (ATAK) team/role values |
|
||||
| `test_traffic_management` | Traffic management |
|
||||
| `test_transmit_history` | Retransmission tracking |
|
||||
| `test_type_conversions` | NodeDB v25 type conversions |
|
||||
|
||||
@@ -1 +1 @@
|
||||
37
|
||||
40
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user