Files
trail-mate/apps/linux_lvgl/tests/linux_node_discovery_test.cpp
T
liu weikai 8bc50ffc22 fix: repair MC and MT node discovery and peer key propagation
Schedule Linux self announcements and retain full MeshCore discover keys.
Align adapter and worker PKI readiness, converge node metadata with public-key
identities, and preserve projected node IDs across persistence and restart.

Avoid routing-prefix self collisions and use local receive time for Nearby
freshness on embedded MC paths. Start MT NodeInfo reply suppression only after
successful transmission.

Add paired-radio discovery/direct-message, directory migration and worker
regressions with English audit and validation notes.
2026-11-08 13:53:15 +08:00

356 lines
15 KiB
C++

#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 <algorithm>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <deque>
#include <filesystem>
#include <string>
#include <unistd.h>
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<uint32_t>(std::time(nullptr)); }
struct Radio final : port::IPacketRadio
{
std::deque<port::RadioPacket> incoming;
std::vector<port::RadioPacket> 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<uint8_t> bytes;
chat::MeshPeerDirectoryBlobLoadResult loadBlob(std::vector<uint8_t>& 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<sys::NodeInfoUpdateEvent*>(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<uint8_t>(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<chat::contacts::PeerBrowseEntry> 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<uint8_t>(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<uint8_t, 32> 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<unsigned>(protocol), static_cast<unsigned>(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<chat::contacts::PeerBrowseEntry> 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);
}