From 4fd3358a45df7e55ea9a0899cd1fc0698ec4cd12 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 24 Oct 2026 20:37:51 -0700 Subject: [PATCH] Add saved node and region discovery response switches --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 2 + docs/cli_commands.md | 48 +++ examples/simple_repeater/MyMesh.cpp | 47 ++- examples/simple_repeater/MyMesh.h | 1 + src/helpers/CommonCLI.cpp | 17 + src/helpers/CommonCLI.h | 5 + src/helpers/DiscoveryResponsePrefs.h | 69 ++++ test/fixtures/protocol_parser_bounds/test.cpp | 84 +++++ test/test_common_radio_persistence.py | 2 + test/test_common_usb_debug.py | 1 + test/test_esp32_cpu_clock.py | 2 + test/test_repeater_discovery.py | 67 +++- test/test_repeater_discovery_settings.py | 355 ++++++++++++++++++ test/test_repeater_guest_access.py | 9 +- 15 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 src/helpers/DiscoveryResponsePrefs.h create mode 100644 test/test_repeater_discovery_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8a61f28ba..7265c164c 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -577,6 +577,7 @@ jobs: python3 -B test/test_companion_ethernet.py -v python3 -B test/test_companion_discovery.py -v python3 -B test/test_repeater_discovery.py -v + python3 -B test/test_repeater_discovery_settings.py -v python3 -B test/test_companion_discovery_correlation.py -v python3 -B test/test_pico_ble_queues.py -v python3 -B test/test_companion_preferences_transaction.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index c9d6a96b0..eb866be78 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -150,6 +150,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes | | Neighbors | [`neighbor.remove [pubkey_prefix]`](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table; omit prefix to clear all | Yes | Yes | Yes | | Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | +| Neighbors | [`get/set discovery.node.responses`; `get/set discovery.region.responses`](cli_commands.md#enable-or-disable-discovery-responses) | Repeater only; newer source, not published 1.18.0.1 binaries; saved, default on, no reboot | Yes | Yes | Yes | | Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | | Statistics | [`clear stats`](cli_commands.md#clear-stats) | All full-parser text CLI roles | Yes | Yes | Yes | | Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local | @@ -325,6 +326,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes | Yes | Yes | | Neighbors | [`neighbor.remove [pubkey_prefix]`](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table; omit prefix to clear all | Yes | Yes | Yes | Yes | Yes | | Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes | +| Neighbors | [`get/set discovery.node.responses`; `get/set discovery.region.responses`](cli_commands.md#enable-or-disable-discovery-responses) | Repeater only; newer source, not published 1.18.0.1 binaries; saved, default on, no reboot | Yes | Yes | Yes | Yes | Yes | | Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | | Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | Yes | Yes | | Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local | Local | Local | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 114671ae6..77862d743 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -636,6 +636,44 @@ require MQTT or PSRAM. --- +### Enable or disable discovery responses + +**Availability:** Repeater firmware in newer source builds. These switches are +not included in the existing published **1.18.0.1** binaries. They do not require +MQTT or PSRAM and are not Companion, Room Server, or Sensor settings. + +```text +get discovery.node.responses +set discovery.node.responses on +set discovery.node.responses off +get discovery.region.responses +set discovery.region.responses on +set discovery.region.responses off +``` + +| Setting | Default | What `off` does | +| --- | --- | --- | +| `discovery.node.responses` | `on` | Stops replies to normal zero-hop node-discovery requests. | +| `discovery.region.responses` | `on` | Stops replies to anonymous region/scope queries, even while adverts remain enabled. | + +The two choices are independent. They apply immediately after a successful +save and survive reboot; no reboot is needed. Fresh installations and upgrades +without saved choices keep both on, preserving the previous behavior. Use a +local console or authenticated administrator CLI to change them. + +Enabling node responses still honors the existing hidden-node rule: when both +`advert.interval` and `flood.advert.interval` are `0`, the repeater does not +answer normal discovery requests. Enabling a response switch also retains the +normal request-validation and rate limits. + +These switches do not stop the node's own adverts, outgoing +`discover.neighbors` or `discover.scopes` commands, or automatic +[MQTT neighbor discovery](#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature). +They do not restrict an authorized administrator's `region` CLI commands. +Use `set mqtt.neighbors off` to stop the periodic outgoing MQTT discovery pass. + +--- + ### Discover neighbor scopes (MQTT observer, neighbors feature) Refreshes the zero-hop neighbor table, then queries each neighbor for its region @@ -649,6 +687,10 @@ scopes and publishes the assembled table to the MQTT `neighbors` topic once. Elsewhere it replies `Err - neighbors not enabled in this build`. If a `discover.neighbors` refresh is already in flight, the scope pass is queued behind it. +This is an outgoing query. A repeater's separate +[discovery response switches](#enable-or-disable-discovery-responses) control +whether that repeater answers other nodes' requests. + --- ## Statistics @@ -6399,6 +6441,12 @@ default-off updates keep the existing record size. > While enabled, `get mqtt.status` gains a trailing `nbr: /` field > (time to next publish, and how the last publish went). +This setting controls this node's outgoing periodic discovery and MQTT +publication. It does not control replies to requests from other nodes. Newer +repeater source builds have separate +[node and region response switches](#enable-or-disable-discovery-responses), +both defaulting to on; those switches are not in the published 1.18.0.1 images. + --- #### View or change the neighbors publish interval (MQTT observer, neighbors feature) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8c422f9ea..3d81d174d 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -748,6 +748,7 @@ static bool hasCompleteAnonReplyPath(const uint8_t* data, size_t data_len) { uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { + if (!(_prefs.discovery_responses & mesh::discovery::REGION_RESPONSES)) return 0; if (!hasCompleteAnonReplyPath(data, data_len)) return 0; if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} @@ -2832,6 +2833,8 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m memcpy(×tamp, data, 4); data[len] = 0; // ensure null terminator + if (data[4] == ANON_REQ_TYPE_REGIONS + && !(_prefs.discovery_responses & mesh::discovery::REGION_RESPONSES)) return; const bool login_request = data[4] == 0 || data[4] >= ' '; uint8_t reply_len; @@ -3579,6 +3582,7 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t void MyMesh::onControlDataRecv(mesh::Packet* packet) { uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 + && (_prefs.discovery_responses & mesh::discovery::NODE_RESPONSES) && !isHiddenNode() // this node wishes to NOT be discoverable && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { @@ -5585,6 +5589,43 @@ void MyMesh::dumpLogFile() { void MyMesh::dumpLogFile() { } #endif +bool MyMesh::handleDiscoveryResponseCommand(const char* command, char* reply, size_t capacity) { + const bool get = strncmp(command, "get ", 4) == 0; + const bool set = strncmp(command, "set ", 4) == 0; + if (!get && !set) return false; + const char* name = command + 4; + const char* setting; + uint8_t flag; + if (strncmp(name, "discovery.node.responses", 24) == 0) { + setting = "discovery.node.responses"; + flag = mesh::discovery::NODE_RESPONSES; + } else if (strncmp(name, "discovery.region.responses", 26) == 0) { + setting = "discovery.region.responses"; + flag = mesh::discovery::REGION_RESPONSES; + } else { + return false; + } + const char* value = name + strlen(setting); + if (*value && *value != ' ' && *value != '\t') return false; + while (*value == ' ' || *value == '\t') ++value; + if ((get && *value) || (set && strcmp(value, "on") && strcmp(value, "off"))) { + snprintf(reply, capacity, "Err - use get %s or set %s on|off", setting, setting); + } else if (get) { + snprintf(reply, capacity, "> %s", (_prefs.discovery_responses & flag) ? "on" : "off"); + } else { + const uint8_t previous = _prefs.discovery_responses; + if (strcmp(value, "on") == 0) _prefs.discovery_responses |= flag; + else _prefs.discovery_responses &= ~flag; + if (_cli.saveCommonPrefs()) { + snprintf(reply, capacity, "OK - %s %s", setting, value); + } else { + _prefs.discovery_responses = previous; + snprintf(reply, capacity, "Err - save unconfirmed; reboot may differ"); + } + } + return true; +} + bool MyMesh::handleGuestCommand(const char* command, char* reply, size_t capacity) { const bool get = strncmp(command, "get guest", 9) == 0; const bool set = strncmp(command, "set guest", 9) == 0; @@ -12762,7 +12803,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * return; } } - if (handleGuestCommand(command, reply, 157) + if (handleDiscoveryResponseCommand(command, reply, 157) + || handleGuestCommand(command, reply, 157) || mesh::cli::handleGuestsGet(acl, command, reply, 157) || mesh::cli::handleLoginsGet(login_history, command, reply, 157)) return; _cli.handleCommand(sender_timestamp, command, reply); @@ -12785,7 +12827,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } } - if (handleGuestCommand(command, reply, 157) + if (handleDiscoveryResponseCommand(command, reply, 157) + || handleGuestCommand(command, reply, 157) || mesh::cli::handleGuestsGet(acl, command, reply, 157) || mesh::cli::handleLoginsGet(login_history, command, reply, 157)) return; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0ed34ea24..4b4c16e00 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -399,6 +399,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks mesh::GuestLoginSettings guest_login; mesh::LoginHistory login_history; bool handleGuestCommand(const char* command, char* reply, size_t capacity); + bool handleDiscoveryResponseCommand(const char* command, char* reply, size_t capacity); ClientACL acl; CommonCLI _cli; #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 4b70f7cf9..549b06e78 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -832,6 +832,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #endif _prefs->usb_debug_enabled = 0; _prefs->trace_when_repeat_off = 0; + _prefs->discovery_responses = mesh::discovery::DEFAULT_RESPONSES; _prefs->ota_channel = 0; #ifdef WITH_RS232_BRIDGE _prefs->rs232_heartbeat = 1; @@ -1105,6 +1106,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // from a previous load of a newer image. _prefs->usb_debug_enabled = 0; _prefs->trace_when_repeat_off = 0; + _prefs->discovery_responses = mesh::discovery::DEFAULT_RESPONSES; _prefs->gps_sync_interval_hours = 0; _prefs->ota_channel = 0; _prefs->rs232_bridge_enabled = 0; @@ -1809,6 +1811,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); recalculateRxPowerSavingFromLevel(_prefs); // retune supported level-based timings to loaded SF/BW + // Read reply controls independently of the current UART profile. Older + // images have no record and retain both replies enabled. + _prefs->discovery_responses = mesh::discovery::readPrefs(file); // Read the footer independently of optional legacy UART tails/profile. #if defined(RAK_4631) _prefs->peripherals_shared = mesh::peripherals::readCommonMode(file); @@ -1983,6 +1988,11 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { prefs->rs232_idle_seconds); WRITE_COMMON_PREFS_BYTES(power_tail, sizeof(power_tail)); #endif + if (prefs->discovery_responses != mesh::discovery::DEFAULT_RESPONSES) { + uint8_t discovery_tail[mesh::discovery::PREFS_SIZE]; + mesh::discovery::encodePrefs(discovery_tail, prefs->discovery_responses); + WRITE_COMMON_PREFS_BYTES(discovery_tail, sizeof(discovery_tail)); + } #if defined(RAK_4631) uint8_t peripheral_footer[mesh::peripherals::FooterSize]; mesh::peripherals::encodeFooter(peripheral_footer, prefs->peripherals_shared); @@ -2078,6 +2088,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _common_save_succeeded = false; _prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0; _prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0; + if (_prefs->discovery_responses > mesh::discovery::DEFAULT_RESPONSES) + _prefs->discovery_responses = mesh::discovery::DEFAULT_RESPONSES; #if defined(ESP32_PLATFORM) if (!mesh::esp32::cpuclock::validSelection(_prefs->cpu_freq_mhz) || (_prefs->cpu_freq_mhz && !_board->supportsCpuFrequencyMHz(_prefs->cpu_freq_mhz))) @@ -2234,6 +2246,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _prefs->rs232_idle_seconds); file.write(power_tail, sizeof(power_tail)); #endif + if (_prefs->discovery_responses != mesh::discovery::DEFAULT_RESPONSES) { + uint8_t discovery_tail[mesh::discovery::PREFS_SIZE]; + mesh::discovery::encodePrefs(discovery_tail, _prefs->discovery_responses); + file.write(discovery_tail, sizeof(discovery_tail)); + } #if defined(RAK_4631) uint8_t peripheral_footer[mesh::peripherals::FooterSize]; mesh::peripherals::encodeFooter(peripheral_footer, _prefs->peripherals_shared); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 38c047c57..c8aa9518f 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -203,6 +204,9 @@ public: // Repeater-only opt-in for routed trace diagnostics while repeat is off. // Appended after usb_debug in /com_prefs; existing images stay disabled. uint8_t trace_when_repeat_off = 0; + // Independent repeater reply gates: bit 0=node, bit 1=anonymous regions. + // Stored in a checked extension before the platform-specific EOF footer. + uint8_t discovery_responses = 3; // Runtime UART choice for merged RS-232 repeater artifacts. Appended at // /com_prefs offset 862; single-UART builds keep their compiled port here. uint8_t bridge_uart = 0; @@ -472,6 +476,7 @@ protected: def("lat", node_lat); def("lon", node_lon); def("disc_mod", discovery_mod_timestamp); + def("disc_responses", discovery_responses); def("ota_ch", ota_channel); // WiFi OTA release channel selector def("radio", radio); def("bridge", bridge); diff --git a/src/helpers/DiscoveryResponsePrefs.h b/src/helpers/DiscoveryResponsePrefs.h new file mode 100644 index 000000000..83dba59f7 --- /dev/null +++ b/src/helpers/DiscoveryResponsePrefs.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace discovery { + +static constexpr uint8_t NODE_RESPONSES = 1; +static constexpr uint8_t REGION_RESPONSES = 2; +static constexpr uint8_t DEFAULT_RESPONSES = NODE_RESPONSES | REGION_RESPONSES; +static constexpr size_t PREFS_SIZE = 8; +static constexpr size_t COMMON_LEGACY_SIZE = 877; +static constexpr size_t LEGACY_RS232_TAIL_SIZE = 10; + +inline uint16_t checksum(const uint8_t* bytes, size_t length) { + uint16_t crc = 0xffff; + for (size_t i = 0; i < length; ++i) { + crc ^= static_cast(bytes[i]) << 8; + for (unsigned bit = 0; bit < 8; ++bit) + crc = static_cast((crc & 0x8000) ? (crc << 1) ^ 0x1021 : crc << 1); + } + return crc; +} + +// The record is omitted when both response types are enabled. Keep it before +// existing platform footers, whose readers locate those policies at EOF. +inline void encodePrefs(uint8_t* out, uint8_t mask) { + out[0] = 'D'; out[1] = 'R'; out[2] = 'P'; out[3] = 1; + out[4] = mask <= DEFAULT_RESPONSES ? mask : DEFAULT_RESPONSES; + out[5] = 0; + const uint16_t crc = checksum(out, 6); + out[6] = static_cast(crc >> 8); + out[7] = static_cast(crc); +} + +inline bool decodePrefs(const uint8_t* in, size_t size, uint8_t& mask) { + mask = DEFAULT_RESPONSES; + if (!in || size != PREFS_SIZE || in[0] != 'D' || in[1] != 'R' + || in[2] != 'P' || in[3] != 1 || in[4] > DEFAULT_RESPONSES || in[5] != 0 + || checksum(in, 6) != static_cast((in[6] << 8) | in[7])) + return false; + mask = in[4]; + return true; +} + +template +uint8_t readPrefs(File& file) { + uint8_t mask = DEFAULT_RESPONSES; + uint8_t bytes[PREFS_SIZE] = {}; + const size_t size = file.size(); + size_t offset = COMMON_LEGACY_SIZE; + if (size < offset + PREFS_SIZE || !file.seek(offset) + || file.read(bytes, sizeof(bytes)) != sizeof(bytes)) + return mask; + // The source image decides whether a UART power tail exists. Depending on + // this build's bridge flags would lose saved settings across role changes. + if (bytes[0] == 'R' && bytes[1] == 'S' && bytes[2] == 'P' && bytes[3] == 1) { + offset += LEGACY_RS232_TAIL_SIZE; + if (size < offset + PREFS_SIZE || !file.seek(offset) + || file.read(bytes, sizeof(bytes)) != sizeof(bytes)) + return mask; + } + decodePrefs(bytes, sizeof(bytes), mask); + return mask; +} + +} // namespace discovery +} // namespace mesh diff --git a/test/fixtures/protocol_parser_bounds/test.cpp b/test/fixtures/protocol_parser_bounds/test.cpp index 8eee5e3ca..bb69f46aa 100644 --- a/test/fixtures/protocol_parser_bounds/test.cpp +++ b/test/fixtures/protocol_parser_bounds/test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "examples/simple_repeater/RateLimiter.h" #include "production_constants.inc" @@ -34,6 +35,7 @@ struct MyMesh { char node_name[32] = "Bellevue"; char owner_info[80] = "owner"; bool disable_fwd = false; + uint8_t discovery_responses = 3; } _prefs; struct Clock { uint32_t now = 123456; @@ -164,6 +166,87 @@ static void checkAnonymousHandlers() { } } +static void checkDiscoveryResponseSwitches() { + const mesh::Identity sender; + const uint8_t secret[PUB_KEY_SIZE] = {}; + mesh::Packet request; + request.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT; + const uint8_t path[] = {0x42, 0x11, 0x22, 0x33, 0x44}; + for (uint8_t node : {0, 1}) { + for (uint8_t region : {0, 1}) { + MyMesh mesh; + mesh._prefs.discovery_responses = node | (region << 1); + mesh.reply_path_len = 0x02; + memset(mesh.reply_data, 0xc7, sizeof(mesh.reply_data)); + const auto limiter_before = mesh.anon_limiter; + const uint8_t length = mesh.handleAnonRegionsReq(sender, 42, path, sizeof(path)); + require((length != 0) == (region != 0), + "node and region discovery choices are not independent"); + if (!region) { + require(mesh.reply_path_len == 0x02, "disabled region request changed reply route"); + for (uint8_t byte : mesh.reply_path) + require(byte == 0xa5, "disabled region request changed reply path bytes"); + for (uint8_t byte : mesh.reply_data) + require(byte == 0xc7, "disabled region request changed reply payload"); + require(mesh.region_map.exports == 0, "disabled region request exported names"); + // Compare future decisions from the real limiter, instead of relying + // on the private counter representation or an access-order mock. + auto expected = limiter_before; + for (unsigned count = 0; count < 10001; ++count) { + require(mesh.anon_limiter.allow(mesh.rtc_clock.now) + == expected.allow(mesh.rtc_clock.now), + "disabled region request consumed anonymous quota"); + } + } else { + require(mesh.reply_path_len == path[0] && mesh.region_map.exports == 1, + "enabled region response lost the requested path or names"); + } + + // Exercise the real receive dispatcher too: no allocation or RF send + // may happen for a disabled query, including an already-known caller. + MyMesh dispatched; + dispatched._prefs.discovery_responses = node | (region << 1); + dispatched.acl.present = true; + dispatched.acl.client.out_path_len = 0; + dispatched.reply_path_len = 0x02; + memset(dispatched.reply_data, 0xc7, sizeof(dispatched.reply_data)); + uint8_t bytes[11] = {42, 0, 0, 0, ANON_REQ_TYPE_REGIONS, + 0x42, 0x11, 0x22, 0x33, 0x44, 0}; + dispatched.onAnonDataRecv(&request, secret, sender, bytes, 10); + require(dispatched.allocations == unsigned(region) + && dispatched.direct_sends == unsigned(region) + && dispatched.flood_sends == 0 && dispatched.logins == 0, + "disabled region query generated TX or fell through to login"); + if (!region) { + require(dispatched.reply_path_len == 0x02, + "disabled dispatched region query changed pending reply route"); + for (uint8_t byte : dispatched.reply_path) + require(byte == 0xa5, "disabled dispatched query changed reply path bytes"); + for (uint8_t byte : dispatched.reply_data) + require(byte == 0xc7, "disabled dispatched query changed reply payload"); + } + + // The switches apply only to public discovery. Both anonymous metadata + // services and login remain reachable with all four combinations. + for (unsigned type : {ANON_REQ_TYPE_OWNER, ANON_REQ_TYPE_BASIC}) { + MyMesh metadata; + metadata._prefs.discovery_responses = node | (region << 1); + bytes[4] = type; + metadata.onAnonDataRecv(&request, secret, sender, bytes, 10); + require(metadata.allocations == 1 && metadata.direct_sends == 1 + && metadata.logins == 0, + "discovery switches disabled owner or clock metadata"); + } + MyMesh login; + login._prefs.discovery_responses = node | (region << 1); + uint8_t credentials[] = {42, 0, 0, 0, 'x', 0}; + login.onAnonDataRecv(&request, secret, sender, credentials, 5); + require(login.logins == 1 && login.allocations == 1, + "discovery switches disabled administrator login"); + } + } +} + static void checkAnonymousDispatch() { const mesh::Identity sender; const uint8_t secret[PUB_KEY_SIZE] = {}; @@ -302,5 +385,6 @@ int main() { checkAdverts(); checkAnonymousHandlers(); checkAnonymousDispatch(); + checkDiscoveryResponseSwitches(); std::puts("PASS: production protocol parser bounds"); } diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index 1ad5af9df..cc0467bfc 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -19,6 +19,7 @@ HARNESS = r''' #define ATOMIC_FILE_WRITER_IMPLEMENTATION #include #include +#include #include #include #if defined(ESP32_PLATFORM) @@ -76,6 +77,7 @@ struct CommonCLI { prefs.freq=910; prefs.bw=62.5f; prefs.sf=7; prefs.cr=5; prefs.tx_power_dbm=30; prefs.primary_radio_preamble=48; prefs.espnow_bridge_enabled=1; + prefs.discovery_responses=3; prefs.gps_sync_interval_hours=336; prefs.rx_ps_rx_us=111; prefs.rx_ps_sleep_us=222; } diff --git a/test/test_common_usb_debug.py b/test/test_common_usb_debug.py index e2057dac3..b4b6a8b5e 100644 --- a/test/test_common_usb_debug.py +++ b/test/test_common_usb_debug.py @@ -35,6 +35,7 @@ class TestFS : public MemoryFS { #include #include #include +#include #include #include #if defined(ESP32_PLATFORM) diff --git a/test/test_esp32_cpu_clock.py b/test/test_esp32_cpu_clock.py index 229f36da4..bf1a4d0a8 100644 --- a/test/test_esp32_cpu_clock.py +++ b/test/test_esp32_cpu_clock.py @@ -248,6 +248,7 @@ inline char* utoa(unsigned value,char* out,int base){ #include #include #include +#include @CONSTANTS@ struct NodePrefs { @FIELDS@ }; @MARKER@ @@ -269,6 +270,7 @@ void non_mqtt(File& file,NodePrefs* _prefs){ @SCALAR@ } int main(){ using namespace mesh::esp32::cpuclock; NodePrefs prefs{};prefs.freq=910.525f;prefs.bw=62.5f;prefs.sf=7;prefs.cr=5; + prefs.discovery_responses=3; prefs.powersaving_enabled=1;strcpy(prefs.node_name,"unchanged prefix"); File legacy;assert(writeCommonPrefsImage(legacy,&prefs)); File nonlegacy;non_mqtt(nonlegacy,&prefs);assert(nonlegacy.bytes==legacy.bytes); diff --git a/test/test_repeater_discovery.py b/test/test_repeater_discovery.py index 07faa876d..60e710db4 100644 --- a/test/test_repeater_discovery.py +++ b/test/test_repeater_discovery.py @@ -24,8 +24,14 @@ HARNESS = r''' #include #include "examples/simple_repeater/RateLimiter.h" #include "src/RadioProfiles.h" +#include "src/helpers/DiscoveryResponsePrefs.h" @CONSTANTS@ #define MESH_DEBUG_PRINTLN(...) ((void)0) +struct CountedLimiter : RateLimiter { + using RateLimiter::RateLimiter; + unsigned calls = 0; + bool allow(uint32_t now) { ++calls; return RateLimiter::allow(now); } +}; static uint32_t now_ms = 100; bool millisHasNowPassed(uint32_t deadline) { return static_cast(now_ms - deadline) > 0; @@ -71,13 +77,14 @@ struct MyMesh { struct { uint16_t advert_interval = 30, flood_advert_interval = 0; bool disable_fwd = false; + uint8_t discovery_responses = 3; uint32_t discovery_mod_timestamp = 500; } _prefs; struct Clock { uint32_t now = 1000; uint32_t getCurrentTime() const { return now; } } rtc_clock; - RateLimiter @LIMITER@; + CountedLimiter @LIMITER@; mesh::Radio radio; mesh::Radio* _radio = &radio; mesh::Identity self_id, neighbour; @@ -206,6 +213,53 @@ static void gates() { unrelated.onControlDataRecv(&packet); require(unrelated.create_attempts == 0, "unrelated control type produced a reply"); } +static void response_switches() { + for (uint8_t node : {0, 1}) { + for (uint8_t region : {0, 1}) { + for (unsigned flags = 0; flags < 4; ++flags) { + MyMesh receiver; + receiver._prefs.discovery_responses = node | (region << 1); + receiver.response.payload_len = 17; + receiver.response.tx_radio = mesh::RADIO_TX_BOTH; + std::memset(receiver.response.payload, 0xa5, sizeof(receiver.response.payload)); + auto packet = request(flags & 1, 0, false, flags & 2); + for (unsigned count = 0; count < 8; ++count) { + receiver.onControlDataRecv(&packet); + } + if (node) { + require(receiver.sent_packets == 4 && receiver.create_attempts == 4, + "region response choice affected normal node discovery"); + } else { + require(receiver.discover_limiter.calls == 0, + "disabled node discovery consumed rate-limit quota"); + require(receiver.sent_packets == 0 && receiver.create_attempts == 0, + "disabled node discovery allocated or transmitted a reply"); + require(receiver.response.payload_len == 17 + && receiver.response.tx_radio == mesh::RADIO_TX_BOTH, + "disabled node discovery changed pending reply metadata"); + for (uint8_t byte : receiver.response.payload) { + require(byte == 0xa5, "disabled node discovery changed reply bytes"); + } + // Turning the live switch back on must admit the complete original + // quota: rejected requests did not secretly exhaust the limiter. + receiver._prefs.discovery_responses |= 1; + for (unsigned count = 1; count <= 5; ++count) { + receiver.onControlDataRecv(&packet); + require(receiver.sent_packets == (count < 5 ? count : 4), + "disabled requests exhausted the later enabled quota"); + } + } + // Enabling responses must not override the legacy hidden-node rule. + MyMesh hidden; + hidden._prefs.discovery_responses = node | (region << 1); + hidden._prefs.advert_interval = hidden._prefs.flood_advert_interval = 0; + hidden.onControlDataRecv(&packet); + require(hidden.create_attempts == 0 && hidden.discover_limiter.calls == 0, + "response setting bypassed hidden-node protection"); + } + } + } +} static void wire() { for (bool prefix : {false, true}) { MyMesh receiver; @@ -333,7 +387,10 @@ static void radio2_disabled() { } } static void received() { + for (uint8_t node : {0, 1}) { + for (uint8_t region : {0, 1}) { MyMesh receiver; + receiver._prefs.discovery_responses = node | (region << 1); receiver._prefs.advert_interval = 0; receiver._prefs.flood_advert_interval = 0; receiver.pending_discover_tag = 0x78563412; @@ -363,11 +420,15 @@ static void received() { receiver.onControlDataRecv(&packet); require(receiver.neighbours == 1 && receiver.pending_discover_tag == 0, "expired discovery response retained its pending request"); + now_ms = 100; + } + } } int main(int argc, char** argv) { if (argc != 2) return 2; if (std::strcmp(argv[1], "policy") == 0) policy(); else if (std::strcmp(argv[1], "gates") == 0) gates(); + else if (std::strcmp(argv[1], "response_switches") == 0) response_switches(); else if (std::strcmp(argv[1], "wire") == 0) wire(); else if (std::strcmp(argv[1], "radio2_wire") == 0) radio2_wire(); else if (std::strcmp(argv[1], "radio2_disabled") == 0) radio2_disabled(); @@ -442,6 +503,10 @@ class RepeaterDiscoveryTests(unittest.TestCase): checked = self.run_case("wire") self.assertEqual(checked.returncode, 0, checked.stderr) + def test_saved_response_switches_are_independent_and_reject_before_quota(self): + checked = self.run_case("response_switches") + self.assertEqual(checked.returncode, 0, checked.stderr) + def test_hidden_nodes_still_process_matching_discovery_responses(self): checked = self.run_case("received") self.assertEqual(checked.returncode, 0, checked.stderr) diff --git a/test/test_repeater_discovery_settings.py b/test/test_repeater_discovery_settings.py new file mode 100644 index 000000000..8114ae258 --- /dev/null +++ b/test/test_repeater_discovery_settings.py @@ -0,0 +1,355 @@ +"""Execute saved repeater discovery controls with real CLI and prefs images.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_repeater_trace_repeat import production_harness +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +MAIN = r''' +struct MyMesh { + CommonCLI _cli; + NodePrefs& _prefs = _cli.prefs; + bool handleDiscoveryResponseCommand(const char*, char*, size_t); +}; +@HANDLER@ + +static void run(MyMesh& mesh, const char* command, const char* expected) { + char reply[160] = {}; + assert(mesh.handleDiscoveryResponseCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, expected) == 0); +} + +static std::vector image(CommonCLI& cli) { + Capture capture; + assert(writeCommonPrefsImage(capture, &cli.prefs)); + return capture.bytes; +} + +static void assertLoaded(const std::vector& bytes, uint8_t expected) { + CommonCLI reader; + reader.prefs.discovery_responses = uint8_t(expected ^ 3); + reader.fs.files["/com_prefs"] = bytes; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.discovery_responses == expected); + assert(reader.prefs.freq == 910 && reader.prefs.primary_radio_preamble == 48); +} + +static void cliChoices() { + for (uint8_t initial : {0, 1, 2, 3}) { + for (uint8_t bit : {1, 2}) { + for (bool enabled : {false, true}) { + MyMesh mesh; + mesh._prefs.discovery_responses = initial; + const char* name = bit == 1 ? "discovery.node.responses" : "discovery.region.responses"; + char command[80], expected[100]; + snprintf(command, sizeof(command), "get %s", name); + run(mesh, command, (initial & bit) ? "> on" : "> off"); + snprintf(command, sizeof(command), "set %s %s", name, enabled ? "on" : "off"); + snprintf(expected, sizeof(expected), "OK - %s %s", name, enabled ? "on" : "off"); + run(mesh, command, expected); + const uint8_t mask = enabled ? uint8_t(initial | bit) : uint8_t(initial & ~bit); + assert(mesh._prefs.discovery_responses == mask); + assert((mesh._prefs.discovery_responses & (bit ^ 3)) == (initial & (bit ^ 3))); + assert(mesh._cli._common_save_succeeded); + assert(mesh._cli.fs.files["/com_prefs"] == image(mesh._cli)); + assertLoaded(mesh._cli.fs.files["/com_prefs"], mask); + snprintf(command, sizeof(command), "get %s", name); + run(mesh, command, enabled ? "> on" : "> off"); + } + } + } + MyMesh mesh; + assert(mesh._prefs.discovery_responses == 3); + run(mesh, "set discovery.node.responses\t \toff", "OK - discovery.node.responses off"); + run(mesh, "set discovery.region.responses off", "OK - discovery.region.responses off"); + assert(mesh._prefs.discovery_responses == 0); +} + +static void invalidCommands() { + for (const char* name : {"discovery.node.responses", "discovery.region.responses"}) { + for (const char* value : {"", " ", "true", "false", "1", "0", "ON", "Off", + "on reboot", "off reboot", "on junk", "off ", "on\t", "on\n", "on;off"}) { + MyMesh mesh; + mesh._prefs.discovery_responses = 1; + mesh._cli.savePrefs(&mesh._cli.fs, PrefsSaveRouting::Scope::Common); + const auto before = mesh._cli.fs.files["/com_prefs"]; + char command[100], reply[160] = {}; + snprintf(command, sizeof(command), "set %s %s", name, value); + assert(mesh.handleDiscoveryResponseCommand(command, reply, sizeof(reply))); + assert(strncmp(reply, "Err - use ", 10) == 0); + assert(mesh._prefs.discovery_responses == 1 && mesh._cli.fs.files["/com_prefs"] == before); + snprintf(command, sizeof(command), "get %s on", name); + assert(mesh.handleDiscoveryResponseCommand(command, reply, sizeof(reply))); + assert(strncmp(reply, "Err - use ", 10) == 0); + assert(mesh._prefs.discovery_responses == 1 && mesh._cli.fs.files["/com_prefs"] == before); + } + } + for (const char* command : {"get discovery.node.responsesx", "set discovery.region.responses_extra off", + "get discovery.node.response", "set discovery.region.responses/off", "discovery.node.responses on"}) { + MyMesh mesh; + const auto before = mesh._cli.fs.files; + char reply[160] = "unchanged"; + assert(!mesh.handleDiscoveryResponseCommand(command, reply, sizeof(reply))); + assert(strcmp(reply, "unchanged") == 0 && mesh._prefs.discovery_responses == 3); + assert(mesh._cli.fs.files == before); + } +} + +static void failedSaves() { + for (uint8_t initial : {0, 1, 2, 3}) { + for (uint8_t bit : {1, 2}) { + for (unsigned fault : {0, 1, 2, 3}) { + MyMesh mesh; + mesh._prefs.discovery_responses = initial; + mesh._cli.savePrefs(&mesh._cli.fs, PrefsSaveRouting::Scope::Common); + assert(mesh._cli._common_save_succeeded); + const auto before = mesh._cli.fs.files["/com_prefs"]; + if (fault == 0) mesh._cli.fs.fail_write = true; + if (fault == 1) { + const size_t offset = mesh::discovery::COMMON_LEGACY_SIZE +#ifdef WITH_RS232_BRIDGE + + mesh::discovery::LEGACY_RS232_TAIL_SIZE +#endif + ; + mesh._cli.fs.fail_write_after = (initial ^ bit) == 3 ? offset - 1 : offset + 4; + } + if (fault == 2) mesh._cli.fs.fail_read_open = true; + if (fault == 3) mesh._cli.fs.fail_rename = 1; + char command[100]; + snprintf(command, sizeof(command), "set discovery.%s.responses %s", + bit == 1 ? "node" : "region", (initial & bit) ? "off" : "on"); + run(mesh, command, "Err - save unconfirmed; reboot may differ"); + assert(mesh._prefs.discovery_responses == initial); + assert(mesh._cli.fs.files["/com_prefs"] == before); + } + } + } +} + +static void persistence() { + CommonCLI cli; + const auto baseline = image(cli); + const size_t offset = mesh::discovery::COMMON_LEGACY_SIZE +#ifdef WITH_RS232_BRIDGE + + mesh::discovery::LEGACY_RS232_TAIL_SIZE +#endif + ; + assert(cli.prefs.discovery_responses == 3); + for (uint8_t mask : {0, 1, 2, 3}) { + cli.prefs.discovery_responses = mask; + cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); + assert(cli._common_save_succeeded); + const auto saved = image(cli); + assert(cli.fs.files["/com_prefs"] == saved); + assert(saved.size() == baseline.size() + (mask == 3 ? 0 : mesh::discovery::PREFS_SIZE)); + assert(std::equal(saved.begin(), saved.begin() + offset, baseline.begin())); + assertLoaded(saved, mask); + if (mask == 3) continue; + uint8_t decoded = 3; + assert(mesh::discovery::decodePrefs(saved.data() + offset, mesh::discovery::PREFS_SIZE, decoded)); + assert(decoded == mask); + + // All historical prefixes and partial optional records keep replies ON, + // even when a previous load into the same object had disabled them. + for (size_t length = 0; length < offset + mesh::discovery::PREFS_SIZE; ++length) { + CommonCLI reader; + reader.prefs.discovery_responses = 0; + reader.fs.files["/com_prefs"] = {saved.begin(), saved.begin() + length}; + reader.loadPrefsInt(&reader.fs, "/com_prefs"); + assert(reader.prefs.discovery_responses == 3); + } + for (size_t byte = 0; byte < mesh::discovery::PREFS_SIZE; ++byte) { + auto damaged = saved; + damaged[offset + byte] ^= 0x80; + assertLoaded(damaged, 3); + } + } + CommonCLI missing; + missing.prefs.discovery_responses = 0; + missing.loadPrefsInt(&missing.fs, "/missing"); + assert(missing.prefs.discovery_responses == 3); + + // A complete, checksummed record with a forbidden mask must still reject. + auto invalid = baseline; + uint8_t record[mesh::discovery::PREFS_SIZE]; + mesh::discovery::encodePrefs(record, 0); + record[4] = 4; + const uint16_t crc = mesh::discovery::checksum(record, 6); + record[6] = uint8_t(crc >> 8); record[7] = uint8_t(crc); + invalid.insert(invalid.begin() + offset, record, record + sizeof(record)); + assertLoaded(invalid, 3); + +#if defined(ESP32_PLATFORM) + // The optional discovery record must not displace the existing CPU footer. + cli.prefs.discovery_responses = 0; + cli.prefs.cpu_freq_mhz = 80; + auto combined = image(cli); + CommonCLI cpu; + cpu.fs.files["/com_prefs"] = combined; + cpu.loadPrefsInt(&cpu.fs, "/com_prefs"); + assert(cpu.prefs.discovery_responses == 0 && cpu.prefs.cpu_freq_mhz == 80); +#endif +#if defined(RAK_4631) + cli.prefs.discovery_responses = 1; + cli.prefs.peripherals_shared = 1; + auto combined = image(cli); + CommonCLI peripheral; + peripheral.fs.files["/com_prefs"] = combined; + peripheral.loadPrefsInt(&peripheral.fs, "/com_prefs"); + assert(peripheral.prefs.discovery_responses == 1 && peripheral.prefs.peripherals_shared == 1); +#endif +#ifndef WITH_RS232_BRIDGE + // Loading an old UART profile on a non-UART image must still find its saved + // discovery record after the source image's checked ten-byte power tail. + CommonCLI plain; + auto uart = image(plain); + uint8_t power[mesh::bridge::rs232power::PREFS_SIZE]; + mesh::bridge::rs232power::encodePrefs(power, 1, 1, 300); + mesh::discovery::encodePrefs(record, 2); + uart.insert(uart.begin() + mesh::discovery::COMMON_LEGACY_SIZE, power, power + sizeof(power)); + uart.insert(uart.begin() + mesh::discovery::COMMON_LEGACY_SIZE + sizeof(power), + record, record + sizeof(record)); + assertLoaded(uart, 2); +#endif +} + +int main() { + cliChoices(); + invalidCommands(); + failedSaves(); + persistence(); +} +''' + +OUTBOUND = r''' +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +#define PAYLOAD_TYPE_ANON_REQ 7 +namespace mesh { +struct Identity { + void calcSharedSecret(uint8_t* out, const Identity&) { memset(out, 0xa5, PUB_KEY_SIZE); } +}; +struct Packet { uint8_t payload[16] = {}; size_t length = 0; }; +} +struct MyMesh { + static constexpr unsigned NEIGHBOR_DISCOVER_MIN_FREE_PACKETS = @MIN_FREE@; + struct { uint8_t discovery_responses = 3; } _prefs; + struct Manager { unsigned free = NEIGHBOR_DISCOVER_MIN_FREE_PACKETS; + unsigned getFreeCount() { return free; } + } manager; + Manager* _mgr = &manager; + struct Clock { uint32_t unique = 1000; uint32_t getCurrentTimeUnique() { return ++unique; } } clock; + Clock* getRTCClock() { return &clock; } + mesh::Identity self_id; + mesh::Packet packet; + bool allocation_ok = true; + unsigned allocations = 0, sends = 0; + mesh::Packet* createAnonDatagram(unsigned type, const mesh::Identity&, + const mesh::Identity&, const uint8_t*, const uint8_t* bytes, size_t length) { + ++allocations; + assert(type == PAYLOAD_TYPE_ANON_REQ && length == 6); + if (!allocation_ok) return nullptr; + memcpy(packet.payload, bytes, length); packet.length = length; + return &packet; + } + void sendDirect(mesh::Packet* sent, const uint8_t* path, uint8_t length, uint32_t delay) { + assert(sent == &packet && path == nullptr && length == 0 && delay == 0); ++sends; + } + mesh::Packet* sendAnonRegionsReq(const mesh::Identity&, uint32_t&); +}; +@CONSTANTS@ +@METHOD@ +int main() { + const mesh::Identity target; + for (uint8_t mask : {0, 1, 2, 3}) { + MyMesh mesh; + mesh._prefs.discovery_responses = mask; + uint32_t tag = 99; + assert(mesh.sendAnonRegionsReq(target, tag) == &mesh.packet); + assert(mesh.allocations == 1 && mesh.sends == 1 && tag == 1001); + uint32_t sent_tag = 0; memcpy(&sent_tag, mesh.packet.payload, sizeof(sent_tag)); + assert(sent_tag == tag && mesh.packet.payload[4] == ANON_REQ_TYPE_REGIONS + && mesh.packet.payload[5] == 0); + mesh.manager.free = MyMesh::NEIGHBOR_DISCOVER_MIN_FREE_PACKETS - 1; + assert(mesh.sendAnonRegionsReq(target, tag) == nullptr); + assert(mesh.allocations == 1 && mesh.sends == 1 && tag == 1001); + mesh.manager.free = MyMesh::NEIGHBOR_DISCOVER_MIN_FREE_PACKETS; + mesh.allocation_ok = false; + assert(mesh.sendAnonRegionsReq(target, tag) == nullptr); + assert(mesh.allocations == 2 && mesh.sends == 1); + } +} +''' + + +class RepeaterDiscoverySettingsTests(unittest.TestCase): + def test_response_choices_do_not_disable_outbound_region_queries(self): + import re + source = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text() + header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text() + minimum = re.search(r'NEIGHBOR_DISCOVER_MIN_FREE_PACKETS\s*=\s*(\d+)', header).group(1) + constant = re.search(r'^#define ANON_REQ_TYPE_REGIONS\s+[^\n]+', source, re.M).group() + code = (OUTBOUND.replace('@MIN_FREE@', minimum).replace('@CONSTANTS@', constant) + .replace('@METHOD@', extract_braced(source, 'mesh::Packet* MyMesh::sendAnonRegionsReq('))) + with tempfile.TemporaryDirectory(prefix='repeater-discovery-outbound-') as directory: + work = Path(directory) + (work / 'test.cpp').write_text(code) + built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-Wall', '-Wextra', '-Werror', '-I' + str(ROOT / 'src'), + str(work / 'test.cpp'), '-o', str(work / 'test')], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + tested = subprocess.run([str(work / 'test')], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_real_commands_saved_images_failures_and_legacy_defaults(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() + header = (ROOT / 'src/helpers/CommonCLI.h').read_text() + repeater = (ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text() + code = production_harness(source, header).split('int main() {', 1)[0] + code = code.replace(' bool trySavePrefs(', ' bool saveCommonPrefs();\n bool trySavePrefs(', 1) + code = ('#include \n#include \n' + '#include \n' + code) + code += extract_braced(source, 'bool CommonCLI::saveCommonPrefs(') + '\n' + code += MAIN.replace('@HANDLER@', extract_braced( + repeater, 'bool MyMesh::handleDiscoveryResponseCommand(')) + sanitizer_flags = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] + variants = [('ENABLE_OTA', platform) for platform in ( + 'NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM')] + variants += [('NRF52_PLATFORM', 'RAK_4631'), + ('ENABLE_OTA', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE'), + ('NRF52_PLATFORM', 'WITH_RS232_BRIDGE', 'WITH_RS232_BRIDGE_UART=2')] + with tempfile.TemporaryDirectory(prefix='repeater-discovery-settings-') as directory: + work = Path(directory) + transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text() + (work / 'ContactFileTransaction.h').write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + (work / 'test.cpp').write_text(code) + for macros in variants: + with self.subTest(macros=macros): + binary = work / ('test.exe' if os.name == 'nt' else 'test') + built = subprocess.run([ + os.environ.get('CXX', 'g++'), '-std=c++17', + *['-D' + macro if '=' in macro else '-D' + macro + '=1' for macro in macros], + *sanitizer_flags, '-I' + str(work), + '-I' + str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/helpers'), + str(work / 'test.cpp'), '-o', str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_repeater_guest_access.py b/test/test_repeater_guest_access.py index a858d8b2f..8e87e05ac 100644 --- a/test/test_repeater_guest_access.py +++ b/test/test_repeater_guest_access.py @@ -22,6 +22,7 @@ HARNESS = r''' #include #include #include +#include #include #include #include @@ -119,7 +120,11 @@ struct MyMesh { ClientACL acl; Clock clock; RNG rng; - struct Prefs { char password[16] = "admin"; char guest_password[16] = ""; } _prefs; + struct Prefs { + char password[16] = "admin"; + char guest_password[16] = ""; + uint8_t discovery_responses = 3; + } _prefs; unsigned long dirty_contacts_expiry = 0; uint8_t contacts_save_failures = 0, reply_data[160]{}; int matching_peer_indexes[MAX_CLIENTS]{}; @@ -573,7 +578,7 @@ class RepeaterGuestAccessTests(unittest.TestCase): "static bool isFilterMgrAllowed(", )) dispatch = extract_braced(source, "void MyMesh::handleCommand(uint32_t sender_timestamp,") - call = dispatch.index("if (handleGuestCommand(command, reply, 157)") + call = dispatch.index("handleGuestCommand(command, reply, 157)") guard_start = dispatch.rindex("if (sender && !sender->isAdmin())", 0, call) guard = extract_braced(dispatch[guard_start:], "if (sender && !sender->isAdmin())") # Exercise the actual authorization branch and setter, excluding unrelated