#include "chat/infra/mesh_peer_directory_core.h" #include "chat/infra/meshcore/meshcore_payload_helpers.h" #include "chat/linux_raw_lora_mesh_adapter.h" #include "chat/runtime/meshcore_runtime.h" #include "chat/usecase/contact_service.h" #include "platform/linux/mesh/linux_sqlite_mesh_identity_store.h" #include "sys/clock.h" #include "sys/event_bus.h" #include #include #include #include #include #include #include #include #include #include namespace { namespace mc = chat::meshcore; namespace wire = trailmate::linux_app::meshcore_wire; namespace port = platform::linux_runtime; using Adapter = trailmate::linux_app::LinuxRawLoraMeshAdapter; uint32_t clock_ms = 100; uint32_t clock_now() { return clock_ms; } uint32_t epoch_now() { return static_cast(std::time(nullptr)); } struct Radio final : port::IPacketRadio { std::deque incoming; std::vector sent; port::RadioSettings settings; bool online = false, fail_tx = false; const char* name() const override { return "discovery fixture"; } bool hardwareCandidatePresent() const override { return true; } bool acquire() override { return online = true; } void release() override { online = false; } bool configureLoRa(const port::RadioSettings& value) override { settings = value; return true; } bool startReceive() override { return online; } bool transmit(const uint8_t* bytes, size_t size) override { assert(size <= port::RadioPacket{}.data.size()); port::RadioPacket packet; packet.size = size; std::copy_n(bytes, size, packet.data.begin()); sent.push_back(packet); return online && !fail_tx; } bool pollReceive(port::RadioPacket* packet) override { if (incoming.empty()) return false; *packet = incoming.front(); incoming.pop_front(); return true; } float readRssi() override { return -90; } bool isOnline() const override { return online; } const char* lastError() const override { return "fixture"; } port::RadioSettings appliedLoRaConfig() const override { return settings; } port::RadioStatistics stats() const override { return {}; } }; struct Blob final : chat::IMeshPeerDirectoryBlobStore { std::vector bytes; chat::MeshPeerDirectoryBlobLoadResult loadBlob(std::vector& out) override { out = bytes; return bytes.empty() ? chat::MeshPeerDirectoryBlobLoadResult::Missing : chat::MeshPeerDirectoryBlobLoadResult::Loaded; } bool saveBlob(const uint8_t* data, size_t size) override { bytes.assign(data, data + size); return true; } void clearBlob() override { bytes.clear(); } }; void useRoot(const std::filesystem::path& root) { std::filesystem::create_directories(root); setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); setenv("TRAIL_MATE_CACHE_ROOT", root.c_str(), 1); } void drain(chat::contacts::ContactService* service = nullptr) { sys::Event* event = nullptr; while (sys::EventBus::subscribe(&event)) { if (service && event->type == sys::EventType::NodeInfoUpdate) { const auto& node = *static_cast(event); service->updateNodeInfo(node.node_id, node.short_name, node.long_name, node.snr, node.rssi, node.event_timestamp, node.protocol, node.role, node.hops_away, node.hw_model, node.channel); } delete event; } } void announcement(const std::filesystem::path& root, chat::MeshProtocol protocol) { useRoot(root); clock_ms = 100; Radio radio; Adapter adapter(radio, 0x12345678, clock_now); chat::MeshConfig config; adapter.applyProtocolConfig(protocol, config); adapter.setUserInfo("Test local node", "TST"); adapter.tick(); assert(radio.sent.empty()); clock_ms += 4999; adapter.tick(); assert(radio.sent.empty()); radio.fail_tx = true; ++clock_ms; adapter.tick(); assert(radio.sent.size() == 1); adapter.tick(); assert(radio.sent.size() == 1); // Failed broadcasts have a bounded retry interval. radio.fail_tx = false; clock_ms += 30000; adapter.tick(); assert(radio.sent.size() == 2); clock_ms += 29999; adapter.tick(); assert(radio.sent.size() == 2); clock_ms += 3U * 60U * 60U * 1000U; adapter.tick(); assert(radio.sent.size() == 3); adapter.setUserInfo("Renamed local node", "NEW"); clock_ms += 5000; adapter.tick(); assert(radio.sent.size() == 4); config.tx_enabled = false; adapter.applyProtocolConfig(protocol, config); clock_ms += 3U * 60U * 60U * 1000U; adapter.tick(); assert(radio.sent.size() == 4); config.tx_enabled = true; adapter.applyProtocolConfig(protocol, config); clock_ms += 5000; adapter.tick(); assert(radio.sent.size() == 5); drain(); } void directoryPromotion() { Blob blob; chat::MeshPeerDirectoryCore directory(blob); chat::contacts::ContactService contacts(directory); contacts.begin(); contacts.setActiveProtocol(chat::MeshProtocol::MeshCore); const uint32_t node = 0x42424242; contacts.updateNodeInfo(node, "PEER", "Named peer", 4, -90, epoch_now(), static_cast(chat::MeshProtocol::MeshCore)); chat::MeshPeerRecord key; key.valid = true; key.identity = chat::makeMeshPeerNodeIdentity(chat::MeshProtocol::MeshCore, node); key.meshcore.node_id_hint = node; key.meshcore.has_public_key = true; std::fill_n(key.meshcore.public_key, 32, 0x42); assert(contacts.recordPeer(key)); assert(directory.count(chat::MeshProtocol::MeshCore) == 1); std::vector nearby; assert(contacts.browsePeers(chat::MeshPeerDirectoryView::Nearby, "", nearby).succeeded()); assert(nearby.size() == 1 && nearby[0].kind == chat::MeshPeerIdentityKind::PublicKey); chat::contacts::PeerDirectoryItem projected; assert(contacts.readPeer(nearby.front(), projected).succeeded()); assert(projected.has_public_key && std::string(projected.long_name) == "Named peer"); assert(contacts.addContact(node, "My peer")); contacts.updateNodeInfo(node, "NEW", "Updated peer", 7, -80, epoch_now(), static_cast(chat::MeshProtocol::MeshCore)); assert(directory.count(chat::MeshProtocol::MeshCore) == 1); assert(contacts.browsePeers(chat::MeshPeerDirectoryView::Contacts, "", nearby).succeeded() && nearby.size() == 1); assert(contacts.readPeer(nearby.front(), projected).succeeded()); assert(projected.display_name == "My peer" && std::string(projected.long_name) == "Updated peer"); assert(directory.flush().succeeded()); chat::MeshPeerDirectoryCore restored(blob); assert(restored.begin().succeeded()); chat::MeshPeerRecord loaded; assert(restored.findByNodeId(chat::MeshProtocol::MeshCore, node, loaded).succeeded()); assert(loaded.identity.kind == chat::MeshPeerIdentityKind::PublicKey && loaded.meshcore.has_public_key); auto missing_hint = loaded; missing_hint.meshcore.node_id_hint = 0; chat::MeshPeerDirectoryCore::encodeBlob(blob.bytes, {missing_hint}); chat::MeshPeerDirectoryCore old_hint(blob); assert(old_hint.begin().succeeded()); assert(old_hint.findByNodeId(chat::MeshProtocol::MeshCore, node, missing_hint).succeeded()); assert(missing_hint.meshcore.node_id_hint == node); // An older cache can contain both identities; refreshing it must retain // the alias and key trust while removing the anonymous duplicate. auto placeholder = loaded; placeholder.identity = chat::makeMeshPeerNodeIdentity(chat::MeshProtocol::MeshCore, node); placeholder.meshcore.has_public_key = false; placeholder.user_alias[0] = '\0'; loaded.meshcore.public_key_verified = true; chat::MeshPeerDirectoryCore::encodeBlob(blob.bytes, {placeholder, loaded}); chat::MeshPeerDirectoryCore legacy(blob); assert(legacy.begin().succeeded() && legacy.count(chat::MeshProtocol::MeshCore) == 2); assert(legacy.record(key).succeeded() && legacy.count(chat::MeshProtocol::MeshCore) == 1); assert(legacy.findByNodeId(chat::MeshProtocol::MeshCore, node, loaded).succeeded()); assert(std::string(loaded.user_alias) == "My peer" && loaded.meshcore.public_key_verified); auto collision = key; collision.meshcore.public_key[31] ^= 1; assert(legacy.record(collision).succeeded()); assert(legacy.count(chat::MeshProtocol::MeshCore) == 2); // Full keys never merge on a short/node-ID collision. } void samePrefixDiscovery() { chat::runtime::MeshCoreRuntime runtime; chat::runtime::RuntimeContext context; context.protocol = chat::MeshProtocol::MeshCore; context.self_node = 0x11111142; std::array key; key.fill(0x42); // Same one-byte routing hash, different node identity. uint8_t payload[38]{}; size_t size = 0; assert(mc::buildDiscoverResponseControlPayload(mc::kMeshCoreAdvertTypeChat, 0, 123, key.data(), key.size(), payload, sizeof(payload), &size)); chat::runtime::IncomingPacket packet; packet.protocol = chat::MeshProtocol::MeshCore; packet.payload_type = mc::kMeshCorePayloadTypeControl; assert(packet.payload.assign(payload, size)); chat::runtime::ProtocolEffects effects; runtime.handleIncomingPacket(packet, context, effects); assert(effects.items.size() == 2); context.self_node = 0x42424242; effects.clear(); runtime.handleIncomingPacket(packet, context, effects); assert(effects.items.empty()); } void exchange(const std::filesystem::path& root, chat::MeshProtocol protocol, chat::MeshCorePayloadSendProfile profile = chat::MeshCorePayloadSendProfile::V2Only) { clock_ms = 100; Radio ar, br; Adapter a(ar, 0x11112222, clock_now), b(br, 0x33334444, clock_now); chat::MeshConfig config; config.meshcore_send_profile = profile; useRoot(root / "a"); a.applyProtocolConfig(protocol, config); a.setUserInfo("Alpha full name", "A"); useRoot(root / "b"); b.applyProtocolConfig(protocol, config); b.setUserInfo("Beta full name", "B"); assert(a.isPkiReady() && b.isPkiReady()); assert(a.getCapabilities().supports_pki && b.getCapabilities().supports_pki); useRoot(root / "a"); assert(a.triggerDiscoveryAction(chat::MeshDiscoveryAction::ScanLocal)); if (protocol == chat::MeshProtocol::MeshCore) assert(ar.sent.size() == 2); // Identity advert precedes anonymous discover request. for (const auto& packet : ar.sent) br.incoming.push_back(packet); useRoot(root / "b"); br.fail_tx = protocol == chat::MeshProtocol::Meshtastic; b.tick(); if (protocol == chat::MeshProtocol::Meshtastic) { assert(br.sent.size() == 1); br.fail_tx = false; useRoot(root / "a"); assert(a.requestNodeInfo(0, true)); useRoot(root / "b"); br.incoming.push_back(ar.sent.back()); b.tick(); assert(br.sent.size() == 2); // A failed reply must not suppress the retry. useRoot(root / "a"); assert(a.requestNodeInfo(0, true)); useRoot(root / "b"); br.incoming.push_back(ar.sent.back()); clock_ms += 30000; b.tick(); // A suppressed request must not slide the successful-reply window. const auto before = br.sent.size(); useRoot(root / "a"); assert(a.requestNodeInfo(0, true)); useRoot(root / "b"); br.incoming.push_back(ar.sent.back()); clock_ms += 30000; b.tick(); assert(br.sent.size() == before + 1); } clock_ms += 2100; b.tick(); assert(!br.sent.empty()); // The scanner learns only DISCOVER_RESP, never B's signed advert. if (protocol == chat::MeshProtocol::Meshtastic) ar.incoming.push_back(br.sent.back()); else for (const auto& packet : br.sent) ar.incoming.push_back(packet); useRoot(root / "a"); a.tick(); if (!a.hasPkiKey(b.getNodeId()) || !b.hasPkiKey(a.getNodeId())) std::fprintf(stderr, "key exchange failed protocol=%u profile=%u a=%08x b=%08x a_has_b=%d b_has_a=%d replies=%zu\n", static_cast(protocol), static_cast(profile), a.getNodeId(), b.getNodeId(), a.hasPkiKey(b.getNodeId()), b.hasPkiKey(a.getNodeId()), br.sent.size()); assert(a.hasPkiKey(b.getNodeId()) && b.hasPkiKey(a.getNodeId())); if (protocol == chat::MeshProtocol::MeshCore) { port::mesh::LinuxSqlitePeerKeyStore keys({"meshcore", "peer_keys", 64}); mesh::PeerPublicKey persisted; assert(keys.get(mesh::NodeId{b.getNodeId()}, persisted).ok); assert(!persisted.verified); // Discovery is not manual verification. } Blob blob; chat::MeshPeerDirectoryCore directory(blob); chat::contacts::ContactService contacts(directory); contacts.begin(); contacts.setActiveProtocol(protocol); drain(&contacts); std::vector nearby; assert(contacts.browsePeers(chat::MeshPeerDirectoryView::Nearby, "", nearby).succeeded()); assert(std::any_of(nearby.begin(), nearby.end(), [&](const auto& peer) { return peer.node_id == b.getNodeId(); })); clock_ms += 3000; // Leave the five-second MC receive guard. a.tick(); const auto sent = a.sendTextDetailed(chat::ChannelId::PRIMARY, "discovered private hello", 101, b.getNodeId()); assert(sent.ok); br.incoming.push_back(ar.sent.back()); useRoot(root / "b"); b.tick(); chat::MeshIncomingText incoming; assert(b.pollIncomingText(&incoming) && incoming.text == "discovered private hello"); drain(); } } // namespace int main() { const auto root = std::filesystem::temp_directory_path() / ("trailmate-node-discovery-" + std::to_string(getpid())); sys::set_millis_provider(clock_now); sys::set_epoch_seconds_provider(epoch_now); sys::EventBus::init(); samePrefixDiscovery(); directoryPromotion(); announcement(root / "announce-mt", chat::MeshProtocol::Meshtastic); announcement(root / "announce-mc", chat::MeshProtocol::MeshCore); exchange(root / "exchange-mt", chat::MeshProtocol::Meshtastic); exchange(root / "exchange-mc", chat::MeshProtocol::MeshCore); exchange(root / "exchange-mc-v1", chat::MeshProtocol::MeshCore, chat::MeshCorePayloadSendProfile::V1Only); sys::set_millis_provider(nullptr); sys::set_epoch_seconds_provider(nullptr); std::filesystem::remove_all(root); }