Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/dapps/dapps.client/Backhaul/Dappsv1SessionBackhaul.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,20 @@ public Dappsv1SessionBackhaul(

/// <summary>
/// AGW handles any route that does not specify a higher-priority
/// bearer like UDP. Effectively: this is the fallback bearer when
/// only callsign + bearer port are known.
/// bearer like UDP or MeshCore. Effectively: this is the fallback bearer
/// when only callsign + bearer port are known.
///
/// The MeshCore exclusion matters for passive discovery (#27): a peer
/// heard only over MeshCore produces a route with a MeshCoreChannel and a
/// null UdpEndpoint. If the MeshCore bearer is currently down (disabled,
/// serial link failed, or not yet started), it declines the route - and
/// without this guard AGW would claim it and attempt a doomed connected-mode
/// session (or spurious RF on a gateway node) to a callsign only ever heard
/// over LoRa. Excluding MeshCore routes leaves it Unreachable so the message
/// waits for MeshCore to return rather than mis-routing over the wrong bearer.
/// </summary>
public bool CanHandle(BackhaulRoute route) => route.UdpEndpoint is null;
public bool CanHandle(BackhaulRoute route) =>
route.UdpEndpoint is null && route.MeshCoreChannel is null;

public async Task<BackhaulSendResult> SendAsync(
BackhaulMessage message,
Expand Down
13 changes: 13 additions & 0 deletions src/dapps/dapps.core.tests/Dappsv1SessionBackhaulTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ public async Task CanHandle_UdpEndpointSet_False()
await Task.CompletedTask;
}

[Fact]
public async Task CanHandle_MeshCoreChannelSet_False()
{
var sb = MakeBackhaul([]);
// AGW must NOT claim a route discovered only over MeshCore (#27). If the
// MeshCore bearer is down (disabled / link failed / not yet started) it
// declines the route; AGW claiming it would mis-route a LoRa-only peer over a
// connected-mode session (spurious RF on a gateway node). Leave it Unreachable.
sb.CanHandle(new BackhaulRoute("N0DEST", BearerPort: 0, MeshCoreChannel: "dapps"))
.Should().BeFalse();
await Task.CompletedTask;
}

[Fact]
public async Task SendAsync_HappyPath_ReturnsOkAndWritesIhaveLine()
{
Expand Down
167 changes: 167 additions & 0 deletions src/dapps/dapps.core.tests/MeshCoreDiscoveryRoutingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using AwesomeAssertions;
using dapps.client.Discovery;
using dapps.core.Models;
using dapps.core.Routing;
using dapps.core.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace dapps.core.tests;

/// <summary>
/// Passive MeshCore discovery (#27): a peer we hear over the MeshCore bearer is
/// recorded as a <see cref="DbDiscoveredPeer"/> with <c>Bearer="meshcore"</c> and a
/// non-null <see cref="DbDiscoveredPeer.MeshCoreChannel"/>. This test pins the
/// routing half of the feature — that <see cref="StaticRoutingAlgorithm"/> turns
/// such a row into a route the MeshCore bearer will claim (its <c>CanHandle</c>
/// keys purely on a non-empty <c>MeshCoreChannel</c>), so outbound to a
/// passively-discovered peer works with no manual neighbour configured.
/// </summary>
[Collection(SqliteOverridePathCollection.Name)]
public sealed class MeshCoreDiscoveryRoutingTests : IAsyncLifetime
{
private string dbPath = null!;
private Database database = null!;
private DatabaseRoutingContext context = null!;
private StaticRoutingAlgorithm algorithm = null!;

private const string OurCallsign = "G0US-1";
private const string PeerCallsign = "G0MC-1";
private const string PeerBaseCallsign = "G0MC";
private const string Channel = "dapps-soak";

public ValueTask InitializeAsync()
{
dbPath = Path.Combine(Path.GetTempPath(), $"dapps-mcdisc-{Guid.NewGuid():N}.db");
DbInfo.OverridePath = dbPath;

using (var c = DbInfo.GetConnection())
{
c.CreateTable<DbOffer>();
c.CreateTable<DbMessage>();
c.CreateTable<DbDroppedMessage>();
c.CreateTable<DbAppToken>();
c.CreateTable<DbNeighbour>();
c.CreateTable<DbRouteHint>();
c.CreateTable<DbDiscoveredPeer>();
c.CreateTable<DbDiscoveryChannel>();
c.CreateTable<DbLearnedRoute>();
c.CreateTable<DbFloodSeen>();
c.CreateTable<DbDiscoveredPath>();
}

var optionsMonitor = new TestOptionsMonitor<SystemOptions>(new SystemOptions { Callsign = OurCallsign });
database = new Database(NullLogger<Database>.Instance, optionsMonitor);
context = new DatabaseRoutingContext(database, optionsMonitor);
algorithm = new StaticRoutingAlgorithm(NullLogger<StaticRoutingAlgorithm>.Instance);
return ValueTask.CompletedTask;
}

public ValueTask DisposeAsync()
{
DbInfo.OverridePath = null;
try { File.Delete(dbPath); } catch { /* ignore */ }
return ValueTask.CompletedTask;
}

private Task Record(DbDiscoveredPeer peer) => database.UpsertDiscoveredPeer(peer);

private static DbDiscoveredPeer MeshCorePeer(DateTime lastSeen) => new()
{
Callsign = PeerCallsign,
Bearer = "meshcore",
ChannelKey = Channel,
LinkClass = LinkClass.MeshCore,
CostHint = LinkClassDefaults.CostHint(LinkClass.MeshCore),
Hops = 1,
TtlSeconds = LinkClassDefaults.AdvertisedTtlSeconds(LinkClass.MeshCore),
MeshCoreChannel = Channel,
LastSeen = lastSeen,
};

private static DbMessage OutboundTo(string baseCallsign) => new()
{
Id = "0000002",
Destination = $"chat@{baseCallsign}-1",
Payload = "x"u8.ToArray(),
};

[Fact]
public async Task FreshMeshCorePeer_RoutesWithChannelHint()
{
await Record(MeshCorePeer(DateTime.UtcNow));

var decision = await algorithm.ResolveAsync(
OutboundTo(PeerBaseCallsign), context, TestContext.Current.CancellationToken);

var nh = decision.Should().BeOfType<RouteDecision.NextHop>().Subject;
nh.Route.Callsign.Should().Be(PeerCallsign);
nh.Route.MeshCoreChannel.Should().Be(Channel);
}

[Fact]
public async Task StalePeer_AgedOut_IsUnreachable()
{
// Older than the MeshCore advertised TTL (10800s) → the freshness
// filter drops it, so there's no route.
await Record(MeshCorePeer(DateTime.UtcNow - TimeSpan.FromSeconds(LinkClassDefaults.AdvertisedTtlSeconds(LinkClass.MeshCore) + 60)));

var decision = await algorithm.ResolveAsync(
OutboundTo(PeerBaseCallsign), context, TestContext.Current.CancellationToken);

decision.Should().BeOfType<RouteDecision.Unreachable>();
}

[Fact]
public async Task NonMeshCorePeer_HasNoChannelHint()
{
// A UDP-heard peer must not gain a MeshCore channel hint — otherwise the
// MeshCore bearer's CanHandle (non-empty MeshCoreChannel) would wrongly
// claim a route that should go out over UDP.
await Record(new DbDiscoveredPeer
{
Callsign = PeerCallsign,
Bearer = "udp",
ChannelKey = "239.0.0.1:5000",
LinkClass = LinkClass.LanMulticast,
CostHint = LinkClassDefaults.CostHint(LinkClass.LanMulticast),
Hops = 1,
TtlSeconds = LinkClassDefaults.AdvertisedTtlSeconds(LinkClass.LanMulticast),
UdpEndpoint = "127.0.0.1:5000",
LastSeen = DateTime.UtcNow,
});

var decision = await algorithm.ResolveAsync(
OutboundTo(PeerBaseCallsign), context, TestContext.Current.CancellationToken);

var nh = decision.Should().BeOfType<RouteDecision.NextHop>().Subject;
nh.Route.MeshCoreChannel.Should().BeNull();
nh.Route.UdpEndpoint.Should().Be("127.0.0.1:5000");
}

[Fact]
public async Task CheaperMeshCore_PreferredOverIpForSamePeer()
{
// Same peer heard on both a MeshCore channel (cost 3, RF-in-spirit) and a
// UDP channel (cost 8). The router must pick the cheaper MeshCore row.
await Record(new DbDiscoveredPeer
{
Callsign = PeerCallsign,
Bearer = "udp",
ChannelKey = "239.0.0.1:5000",
LinkClass = LinkClass.LanMulticast,
CostHint = LinkClassDefaults.CostHint(LinkClass.LanMulticast),
Hops = 1,
TtlSeconds = LinkClassDefaults.AdvertisedTtlSeconds(LinkClass.LanMulticast),
UdpEndpoint = "127.0.0.1:5000",
LastSeen = DateTime.UtcNow,
});
await Record(MeshCorePeer(DateTime.UtcNow));

var decision = await algorithm.ResolveAsync(
OutboundTo(PeerBaseCallsign), context, TestContext.Current.CancellationToken);

var nh = decision.Should().BeOfType<RouteDecision.NextHop>().Subject;
nh.Route.MeshCoreChannel.Should().Be(Channel);
}
}
5 changes: 5 additions & 0 deletions src/dapps/dapps.core/Models/DbDiscoveredPeer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public sealed class DbDiscoveredPeer
/// (UDP only).</summary>
public string? UdpEndpoint { get; set; }

/// <summary>MeshCore channel name the peer was heard on (MeshCore bearer only),
/// so the router can build a MeshCore route back to a passively-discovered peer
/// without a manual neighbour (#27). Null for other bearers.</summary>
public string? MeshCoreChannel { get; set; }

public DateTime LastSeen { get; set; }

public static string MakeKey(string callsign, string bearer, string channelKey)
Expand Down
3 changes: 2 additions & 1 deletion src/dapps/dapps.core/Routing/StaticRoutingAlgorithm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ public async Task<RouteDecision> ResolveAsync(DbMessage message, IRoutingContext
return new RouteDecision.NextHop(new BackhaulRoute(
freshPeer.Callsign,
BearerPort: freshPeer.BearerPort ?? ctx.DefaultBearerPort,
UdpEndpoint: freshPeer.UdpEndpoint));
UdpEndpoint: freshPeer.UdpEndpoint,
MeshCoreChannel: freshPeer.MeshCoreChannel));
}

// 3. Hand-maintained route hint. The fallback for "I know peer
Expand Down
103 changes: 101 additions & 2 deletions src/dapps/dapps.core/Services/MeshCoreBearer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using dapps.client.Backhaul;
using dapps.client.Discovery;
using dapps.client.Tx;
using dapps.core.Models;
using dapps.meshcore;
Expand All @@ -21,6 +22,7 @@ public sealed class MeshCoreBearer : IDappsBackhaul, IAsyncDisposable
private readonly IOptionsMonitor<SystemOptions> _sysOpts;
private readonly IBackhaulInbox _inbox;
private readonly IDappsTxGate _txGate;
private readonly Database _database;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<MeshCoreBearer> _log;

Expand All @@ -29,6 +31,13 @@ public sealed class MeshCoreBearer : IDappsBackhaul, IAsyncDisposable
private MeshCoreInbound? _inbound;
private MeshCoreReliability? _reliability;

// Passive discovery (#27): throttle DB upserts per peer so a chatty channel
// doesn't hammer SQLite — we only need to refresh a peer's freshness, not record
// every frame. Keyed on the source callsign; guarded by its own lock.
private static readonly TimeSpan DiscoveryUpsertThrottle = TimeSpan.FromSeconds(30);
private readonly object _discoveryLock = new();
private readonly Dictionary<string, DateTime> _lastDiscoveryUpsert = new(StringComparer.OrdinalIgnoreCase);

public bool Enabled { get; private set; }
public MeshCoreLink? Link => _link;
public MeshCoreInbound? Inbound => _inbound;
Expand Down Expand Up @@ -61,11 +70,13 @@ public MeshCoreBearer(
IOptionsMonitor<SystemOptions> sysOpts,
IBackhaulInbox inbox,
IDappsTxGate txGate,
Database database,
ILoggerFactory loggerFactory)
{
_sysOpts = sysOpts;
_inbox = inbox;
_txGate = txGate;
_database = database;
_loggerFactory = loggerFactory;
_log = loggerFactory.CreateLogger<MeshCoreBearer>();
}
Expand Down Expand Up @@ -104,15 +115,42 @@ public async Task RunAsync(CancellationToken ct)
_link, _inbox, _loggerFactory.CreateLogger<MeshCoreInbound>(),
reliability,
sendAck: (ack, c) => backhaul.ResendAsync(ack, opts.LocalCallsign, c),
localCallsign: opts.LocalCallsign);
localCallsign: opts.LocalCallsign,
onPeerHeard: (src, c) => RecordPeerHeardAsync(src, opts, c));
Enabled = true;

// Reliability resend loop runs alongside the inbound drain loop.
var resendTask = reliability is not null
? Task.Run(() => ResendLoopAsync(reliability, backhaul, ct), ct)
: Task.CompletedTask;
// Age out stale discovered peers ourselves. On a MeshCore-only node there's no
// AGW/UDP discovery channel, so DiscoveryService (the usual sweeper) never runs
// its age-out; without this, passively-recorded rows would accumulate for the
// node's lifetime (#27 review). Bearer-agnostic and idempotent, so it's harmless
// to also run on a mixed node where DiscoveryService sweeps too.
var housekeepingTask = Task.Run(() => AgeOutLoopAsync(ct), ct);
try { await _inbound.RunAsync(ct); }
finally { try { await resendTask; } catch { /* shutdown */ } }
finally
{
try { await resendTask; } catch { /* shutdown */ }
try { await housekeepingTask; } catch { /* shutdown */ }
}
}

private async Task AgeOutLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(TimeSpan.FromMinutes(5), ct); }
catch (OperationCanceledException) { break; }
try
{
var aged = await _database.AgeOutDiscoveredPeers(DateTime.UtcNow);
if (aged.Count > 0)
_log.LogInformation("MeshCore: aged out {0} stale discovered peer(s)", aged.Count);
}
catch (Exception ex) { _log.LogDebug("MeshCore: discovered-peer age-out failed: {0}", ex.Message); }
}
}

private async Task ResendLoopAsync(MeshCoreReliability reliability, MeshCoreCompanionBackhaul backhaul, CancellationToken ct)
Expand All @@ -138,6 +176,67 @@ private async Task ResendLoopAsync(MeshCoreReliability reliability, MeshCoreComp
}
}

/// <summary>Passive discovery (#27): record a peer we heard over MeshCore as a
/// fresh <see cref="DbDiscoveredPeer"/> so the router can send to it without a
/// manual neighbour. Throttled per peer, skips ourselves (a repeater may echo our
/// own frames), and never lets a DB fault break the inbound drain loop.</summary>
private async Task RecordPeerHeardAsync(string source, MeshCoreBearerOptions opts, CancellationToken ct)
{
// A repeater re-broadcasting our own frame would otherwise teach us a route to
// ourselves. Compare on the base callsign (SSID-insensitive), like the router.
// Use the LIVE callsign, not the one captured at bearer start: outbound frames
// are stamped with the current callsign, so after a runtime rename an echo of
// our own frame must still be recognised as self.
var localCallsign = _sysOpts.CurrentValue.Callsign ?? "";
if (string.Equals(source.Split('-')[0], localCallsign.Split('-')[0], StringComparison.OrdinalIgnoreCase))
return;

var now = DateTime.UtcNow;
lock (_discoveryLock)
{
if (_lastDiscoveryUpsert.TryGetValue(source, out var last) && now - last < DiscoveryUpsertThrottle)
return;
// Bound the dict: an entry older than the throttle window no longer gates
// anything, so drop stale ones (also caps memory if the channel injects
// many distinct callsigns). Cheap — runs only when we're about to upsert.
if (_lastDiscoveryUpsert.Count > 0)
{
var cutoff = now - DiscoveryUpsertThrottle;
foreach (var k in _lastDiscoveryUpsert.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList())
_lastDiscoveryUpsert.Remove(k);
}
_lastDiscoveryUpsert[source] = now;
}

var peer = new DbDiscoveredPeer
{
Callsign = source,
Bearer = "meshcore",
ChannelKey = opts.ChannelName,
ChannelId = 0,
LinkClass = LinkClass.MeshCore,
CostHint = LinkClassDefaults.CostHint(LinkClass.MeshCore),
Hops = 1,
TtlSeconds = LinkClassDefaults.AdvertisedTtlSeconds(LinkClass.MeshCore),
MeshCoreChannel = opts.ChannelName,
LastSeen = now,
};
try
{
await _database.UpsertDiscoveredPeer(peer);
_log.LogInformation("MeshCore: discovered peer {0} on {1} (cost={2}, ttl={3}s)",
source, opts.ChannelName, peer.CostHint, peer.TtlSeconds);
}
catch (Exception ex)
{
// Keep the throttle stamp: on a persistent DB fault, rolling it back would
// let every subsequent frame from this peer re-attempt (and re-fail) the
// upsert, defeating the 30s rate-limit exactly when the DB is unhealthy. A
// retry still happens on the next frame after the window, which is enough.
_log.LogWarning("MeshCore: failed to record discovered peer {0}: {1}", source, ex.Message);
}
}

public bool CanHandle(BackhaulRoute route) =>
Enabled && _backhaul is not null && _backhaul.CanHandle(route);

Expand Down
Loading