diff --git a/src/dapps/dapps.core.tests/MeshCoreConfigTests.cs b/src/dapps/dapps.core.tests/MeshCoreConfigTests.cs index d51a861..ed1d477 100644 --- a/src/dapps/dapps.core.tests/MeshCoreConfigTests.cs +++ b/src/dapps/dapps.core.tests/MeshCoreConfigTests.cs @@ -41,7 +41,9 @@ public async Task MeshCoreOptions_RoundTripThroughStore() var opts = store.CurrentValue; opts.MeshCoreEnabled = true; opts.MeshCorePort = "/dev/ttyUSB9"; - opts.MeshCoreRegion = "uk-narrow"; + opts.MeshCoreRegion = "custom"; + opts.MeshCoreCustomPreset = "freq=867.1;bw=125;sf=9;cr=6;pwr=20"; + opts.MeshCoreFloodScopeKey = "dapps-uk"; opts.MeshCoreChannelIndex = 3; opts.MeshCoreChannelName = "testch"; opts.MeshCoreChannelPsk = "3135135fd198029d689b64f45df2aae9"; @@ -59,7 +61,9 @@ public async Task MeshCoreOptions_RoundTripThroughStore() var reloaded = new SystemOptionsStore(NullLogger.Instance).CurrentValue; reloaded.MeshCoreEnabled.Should().BeTrue(); reloaded.MeshCorePort.Should().Be("/dev/ttyUSB9"); - reloaded.MeshCoreRegion.Should().Be("uk-narrow"); + reloaded.MeshCoreRegion.Should().Be("custom"); + reloaded.MeshCoreCustomPreset.Should().Be("freq=867.1;bw=125;sf=9;cr=6;pwr=20"); + reloaded.MeshCoreFloodScopeKey.Should().Be("dapps-uk"); reloaded.MeshCoreChannelIndex.Should().Be(3); reloaded.MeshCoreChannelName.Should().Be("testch"); reloaded.MeshCoreChannelPsk.Should().Be("3135135fd198029d689b64f45df2aae9"); diff --git a/src/dapps/dapps.core.tests/MeshCoreDeploymentModelTests.cs b/src/dapps/dapps.core.tests/MeshCoreDeploymentModelTests.cs new file mode 100644 index 0000000..af25da1 --- /dev/null +++ b/src/dapps/dapps.core.tests/MeshCoreDeploymentModelTests.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; +using System.Text; +using AwesomeAssertions; +using dapps.meshcore; +using Xunit; + +namespace dapps.core.tests; + +/// +/// Deployment models B/C (#24): the bearer exposes preset + flood-scope as first-class +/// config. Model A = unscoped public preset, B = scoped public preset (flood-scope key), +/// C = dedicated/custom preset (own freq/SF). These tests pin the config resolution - +/// the on-air behaviour of the scope key is validated by the soak. +/// +public sealed class MeshCoreDeploymentModelTests +{ + // ── Model C: custom dedicated preset ─────────────────────────── + + [Fact] + public void ParseCustom_ValidSpec_ProducesPreset() + { + var p = Regions.ParseCustom("freq=868.4;bw=62.5;sf=8;cr=8;pwr=14"); + p.Name.Should().Be(Regions.CustomName); + p.FreqMhz.Should().Be(868.4); + p.BwKhz.Should().Be(62.5); + p.Sf.Should().Be(8); + p.Cr.Should().Be(8); + p.MaxPowerDbm.Should().Be(14); + } + + [Fact] + public void ParseCustom_ToleratesCommasAndWhitespace() + { + var p = Regions.ParseCustom(" freq=869.5 , bw=250 , sf=11 , cr=5 , pwr=27 "); + p.FreqMhz.Should().Be(869.5); + p.Sf.Should().Be(11); + p.MaxPowerDbm.Should().Be(27); + } + + [Theory] + [InlineData("")] // empty + [InlineData("freq=868.4;bw=62.5;sf=8;cr=8")] // missing pwr + [InlineData("freq=868.4;bw=62.5;sf=99;cr=8;pwr=14")] // sf out of range + [InlineData("freq=868.4;bw=62.5;sf=8;cr=8;pwr=99")] // pwr out of range + [InlineData("freq=abc;bw=62.5;sf=8;cr=8;pwr=14")] // freq not a number + [InlineData("freq;bw=62.5;sf=8;cr=8;pwr=14")] // malformed field + public void ParseCustom_Rejects_BadSpecs(string spec) + { + var act = () => Regions.ParseCustom(spec); + act.Should().Throw(); + } + + [Fact] + public void ResolveRegion_Custom_UsesCustomPreset() + { + var opts = new MeshCoreBearerOptions { Region = "custom", CustomPreset = "freq=867.1;bw=125;sf=9;cr=6;pwr=20" }; + var p = opts.ResolveRegion(); + p.FreqMhz.Should().Be(867.1); + p.Sf.Should().Be(9); + } + + [Fact] + public void ResolveRegion_BakedName_StillWorks() + { + new MeshCoreBearerOptions { Region = "uk-test" }.ResolveRegion().FreqMhz.Should().Be(868.4); + } + + // ── Model B: flood-scope key derivation ──────────────────────── + + [Fact] + public void ResolveFloodScopeKey_Empty_IsUnscoped() + { + new MeshCoreBearerOptions { FloodScopeKey = "" }.ResolveFloodScopeKey().Should().BeNull(); + new MeshCoreBearerOptions { FloodScopeKey = " " }.ResolveFloodScopeKey().Should().BeNull(); + } + + [Fact] + public void ResolveFloodScopeKey_RegionName_HashesLikeMeshCorePublicKey() + { + // MeshCore derives a public region key as SHA256("#"+name)[..16]; matching that + // lets repeaters configured with `region put ` carry our scoped floods. + var key = new MeshCoreBearerOptions { FloodScopeKey = "dapps-uk" }.ResolveFloodScopeKey(); + var expected = SHA256.HashData(Encoding.UTF8.GetBytes("#dapps-uk"))[..16]; + key.Should().Equal(expected); + key!.Length.Should().Be(16); + } + + [Fact] + public void ResolveFloodScopeKey_HexString_IsVerbatim() + { + var hex = "00112233445566778899aabbccddeeff"; + var key = new MeshCoreBearerOptions { FloodScopeKey = hex }.ResolveFloodScopeKey(); + Convert.ToHexString(key!).ToLowerInvariant().Should().Be(hex); + } + + [Fact] + public void ResolveFloodScopeKey_AllZeroHex_IsUnscoped() + { + // An all-zero key is what the radio treats as unscoped, so it must resolve to null + // (and classify as model A) - not read as a scoped key the label/logs would lie about. + var opts = new MeshCoreBearerOptions { FloodScopeKey = new string('0', 32) }; + opts.ResolveFloodScopeKey().Should().BeNull(); + opts.DeploymentModel().Should().StartWith("A"); + } + + // ── DeploymentModel classification ───────────────────────────── + + [Theory] + [InlineData("uk-narrow", "", "A")] // unscoped public preset + [InlineData("uk-narrow", "dapps-uk", "B")] // scoped public preset + [InlineData("custom", "", "C")] // dedicated preset (scope irrelevant) + [InlineData("custom", "dapps-uk", "C")] // dedicated wins over scope in the label + public void DeploymentModel_Classifies(string region, string scope, string expectedPrefix) + { + var opts = new MeshCoreBearerOptions { Region = region, FloodScopeKey = scope }; + opts.DeploymentModel().Should().StartWith(expectedPrefix); + } +} diff --git a/src/dapps/dapps.core/Models/SystemOptions.cs b/src/dapps/dapps.core/Models/SystemOptions.cs index 1cbd182..a823231 100644 --- a/src/dapps/dapps.core/Models/SystemOptions.cs +++ b/src/dapps/dapps.core/Models/SystemOptions.cs @@ -96,9 +96,20 @@ public class SystemOptions public string MeshCorePort { get; set; } = "/dev/ttyUSB0"; /// Region preset (localisation): uk-narrow, uk-test, - /// eu-legacy. Sets frequency/BW/SF/CR and caps TX power. + /// eu-legacy, or custom (deployment model C - use + /// ). Sets frequency/BW/SF/CR and caps TX power. public string MeshCoreRegion { get; set; } = "uk-test"; + /// Deployment model C: when is custom, the + /// dedicated-preset spec for a DAPPS-only frequency/SF, e.g. + /// freq=868.4;bw=62.5;sf=8;cr=8;pwr=14. Ignored for baked regions. + public string MeshCoreCustomPreset { get; set; } = ""; + + /// Deployment model B: flood-scope key. Empty = unscoped (model A - floods + /// carried network-wide by any same-preset public repeater). Non-empty scopes our + /// floods so nodes/repeaters not sharing it drop them. See the MeshCore README. + public string MeshCoreFloodScopeKey { get; set; } = ""; + /// TX power in dBm (capped by the region's regulatory max). public int MeshCoreTxPowerDbm { get; set; } = 8; diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml b/src/dapps/dapps.core/Pages/Settings.cshtml index 35e4815..143dd74 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml +++ b/src/dapps/dapps.core/Pages/Settings.cshtml @@ -99,8 +99,11 @@ + +
model C, when region = custom: freq=868.4;bw=62.5;sf=8;cr=8;pwr=14
+
model B: blank = unscoped; set to contain floods to scoped nodes
capped by the region's max
0 = public; 1+ = private
@@ -254,6 +257,8 @@ async function dappsSaveConfig(ev) { MeshCoreEnabled: f.MeshCoreEnabled.checked, MeshCorePort: f.MeshCorePort.value, MeshCoreRegion: f.MeshCoreRegion.value, + MeshCoreCustomPreset: f.MeshCoreCustomPreset.value, + MeshCoreFloodScopeKey: f.MeshCoreFloodScopeKey.value, MeshCoreTxPowerDbm: parseInt(f.MeshCoreTxPowerDbm.value, 10), MeshCoreChannelIndex: parseInt(f.MeshCoreChannelIndex.value, 10), MeshCoreChannelName: f.MeshCoreChannelName.value, diff --git a/src/dapps/dapps.core/Services/DbStartup.cs b/src/dapps/dapps.core/Services/DbStartup.cs index 62926d1..f3956fe 100644 --- a/src/dapps/dapps.core/Services/DbStartup.cs +++ b/src/dapps/dapps.core/Services/DbStartup.cs @@ -94,6 +94,8 @@ private static readonly (string Key, string Default)[] SeededOptions = ("MeshCoreEnabled", "false"), ("MeshCorePort", "/dev/ttyUSB0"), ("MeshCoreRegion", "uk-test"), + ("MeshCoreCustomPreset", ""), + ("MeshCoreFloodScopeKey", ""), ("MeshCoreTxPowerDbm", "8"), ("MeshCoreChannelIndex", "1"), ("MeshCoreChannelName", "dapps"), diff --git a/src/dapps/dapps.core/Services/MeshCoreBearer.cs b/src/dapps/dapps.core/Services/MeshCoreBearer.cs index 8d85ec5..da139e1 100644 --- a/src/dapps/dapps.core/Services/MeshCoreBearer.cs +++ b/src/dapps/dapps.core/Services/MeshCoreBearer.cs @@ -96,15 +96,19 @@ public async Task RunAsync(CancellationToken ct) var budget = new TxBudget(opts.AirtimeBudgetSecPerHour); var reliability = opts.ReliableDelivery ? new MeshCoreReliability() : null; _reliability = reliability; - _link = new MeshCoreLink(opts, _loggerFactory.CreateLogger()); try { + // Construct inside the try: the ctor resolves the region preset, and a bad + // custom preset (model C) throws from ParseCustom - a config error must + // disable the bearer gracefully, not fault the hosted service and crash the + // daemon (BackgroundServiceExceptionBehavior.StopHost). + _link = new MeshCoreLink(opts, _loggerFactory.CreateLogger()); await _link.StartAsync(ct); } catch (Exception ex) { - _log.LogError(ex, "MeshCore bearer failed to start on {0}", opts.SerialPort); + _log.LogError(ex, "MeshCore bearer failed to start (port={0}, region={1})", opts.SerialPort, opts.Region); return; } @@ -250,6 +254,8 @@ public Task SendAsync( Enabled = true, SerialPort = s.MeshCorePort, Region = s.MeshCoreRegion, + CustomPreset = s.MeshCoreCustomPreset, + FloodScopeKey = s.MeshCoreFloodScopeKey, TxPowerDbm = (byte)Math.Clamp(s.MeshCoreTxPowerDbm, 0, 30), ChannelIndex = (byte)Math.Clamp(s.MeshCoreChannelIndex, 0, 255), ChannelName = s.MeshCoreChannelName, diff --git a/src/dapps/dapps.core/Services/SystemOptionsStore.cs b/src/dapps/dapps.core/Services/SystemOptionsStore.cs index df549d0..977e27a 100644 --- a/src/dapps/dapps.core/Services/SystemOptionsStore.cs +++ b/src/dapps/dapps.core/Services/SystemOptionsStore.cs @@ -109,6 +109,8 @@ public async Task SaveAsync(SystemOptions options) await Upsert(connection, existing, nameof(options.MeshCoreEnabled), options.MeshCoreEnabled.ToString()); await Upsert(connection, existing, nameof(options.MeshCorePort), options.MeshCorePort); await Upsert(connection, existing, nameof(options.MeshCoreRegion), options.MeshCoreRegion); + await Upsert(connection, existing, nameof(options.MeshCoreCustomPreset), options.MeshCoreCustomPreset); + await Upsert(connection, existing, nameof(options.MeshCoreFloodScopeKey), options.MeshCoreFloodScopeKey); await Upsert(connection, existing, nameof(options.MeshCoreTxPowerDbm), options.MeshCoreTxPowerDbm.ToString()); await Upsert(connection, existing, nameof(options.MeshCoreChannelIndex), options.MeshCoreChannelIndex.ToString()); await Upsert(connection, existing, nameof(options.MeshCoreChannelName), options.MeshCoreChannelName); @@ -190,6 +192,8 @@ private static SystemOptions Parse(Dictionary r) MeshCoreEnabled = TryGetBool(r, nameof(SystemOptions.MeshCoreEnabled), false), MeshCorePort = TryGet(r, nameof(SystemOptions.MeshCorePort), "/dev/ttyUSB0"), MeshCoreRegion = TryGet(r, nameof(SystemOptions.MeshCoreRegion), "uk-test"), + MeshCoreCustomPreset = TryGet(r, nameof(SystemOptions.MeshCoreCustomPreset), ""), + MeshCoreFloodScopeKey = TryGet(r, nameof(SystemOptions.MeshCoreFloodScopeKey), ""), MeshCoreTxPowerDbm = TryGetInt(r, nameof(SystemOptions.MeshCoreTxPowerDbm), 8, min: 0, max: 30), MeshCoreChannelIndex = TryGetInt(r, nameof(SystemOptions.MeshCoreChannelIndex), 1, min: 0, max: 255), MeshCoreChannelName = TryGet(r, nameof(SystemOptions.MeshCoreChannelName), "dapps"), diff --git a/src/dapps/dapps.meshcore.soak/Program.cs b/src/dapps/dapps.meshcore.soak/Program.cs index 21412e8..854c01e 100644 --- a/src/dapps/dapps.meshcore.soak/Program.cs +++ b/src/dapps/dapps.meshcore.soak/Program.cs @@ -25,6 +25,8 @@ Enabled = true, SerialPort = a.Get("port", "/dev/ttyUSB0"), Region = a.Get("region", "uk-test"), + CustomPreset = a.Get("custom-preset", ""), // model C: with --region custom + FloodScopeKey = a.Get("flood-scope", ""), // model B: scope key/name TxPowerDbm = (byte)a.GetInt("tx-power", 8), ChannelIndex = (byte)a.GetInt("channel-index", 2), ChannelName = a.Get("channel", "dapps-soak"), diff --git a/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs b/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs index 39843ad..efdb52b 100644 --- a/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs +++ b/src/dapps/dapps.meshcore/MeshCoreBearerOptions.cs @@ -10,6 +10,18 @@ public sealed class MeshCoreBearerOptions public bool Enabled { get; set; } public string SerialPort { get; set; } = "/dev/ttyUSB0"; public string Region { get; set; } = "uk-test"; + + /// Deployment model C: when is "custom", the dedicated + /// preset spec (e.g. freq=868.4;bw=62.5;sf=8;cr=8;pwr=14) for a DAPPS-only + /// frequency/SF. Ignored for baked regions. See README "Containment". + public string CustomPreset { get; set; } = ""; + + /// Deployment model B: flood-scope key. Empty = unscoped (model A - floods are + /// carried network-wide by any same-preset public repeater). Non-empty tells the radio + /// to tag our floods with this scope so nodes/repeaters that don't share it drop them, + /// containing DAPPS traffic to our own scoped infra. See README "Containment" for the + /// firmware caveats. + public string FloodScopeKey { get; set; } = ""; public byte TxPowerDbm { get; set; } = 8; public byte ChannelIndex { get; set; } = 1; public string ChannelName { get; set; } = "dapps"; @@ -38,7 +50,18 @@ public sealed class MeshCoreBearerOptions public string LocalCallsign { get; set; } = ""; public RegionPreset ResolveRegion() => - Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'"); + Region.Equals(Regions.CustomName, StringComparison.OrdinalIgnoreCase) + ? Regions.ParseCustom(CustomPreset) + : Regions.Find(Region) ?? throw new ArgumentException($"unknown MeshCore region '{Region}'"); + + /// Which deployment model this config selects, for logging/observability. + /// A = unscoped public preset, B = scoped public preset, C = dedicated/custom preset. + public string DeploymentModel() + { + bool scoped = ResolveFloodScopeKey() is not null; // resolved, so an all-zero key reads as unscoped + bool dedicated = Region.Equals(Regions.CustomName, StringComparison.OrdinalIgnoreCase); + return dedicated ? "C (dedicated preset)" : scoped ? "B (scoped public preset)" : "A (unscoped public preset)"; + } /// The 16-byte channel secret: a 32-char hex string is used verbatim, /// otherwise the value is treated as a passphrase and hashed to 16 bytes. @@ -49,4 +72,24 @@ public byte[] ResolvePsk() return Convert.FromHexString(v); return SHA256.HashData(Encoding.UTF8.GetBytes(v))[..16]; } + + /// The 16-byte flood-scope key (deployment model B), or null when unscoped + /// (empty ). A 32-char hex string is used verbatim; any + /// other value is treated as a PUBLIC region NAME and hashed as SHA256("#"+name)[..16] + /// - the same derivation MeshCore uses for public region keys, so repeaters configured + /// with region put <name> (flood-allowed) carry our traffic and everyone + /// else drops it. A truly-secret key would be dropped network-wide: the firmware's + /// private ($-prefixed) keystore is stubbed in v1.16.x. + public byte[]? ResolveFloodScopeKey() + { + var v = FloodScopeKey?.Trim() ?? ""; + if (v.Length == 0) return null; + var key = (v.Length == 32 && v.All(Uri.IsHexDigit)) + ? Convert.FromHexString(v) + : SHA256.HashData(Encoding.UTF8.GetBytes("#" + v))[..16]; + // An all-zero key is what the radio treats as "unscoped" (SetFloodScopeAsync sends + // the clear frame for it), so report it as unscoped here too - otherwise + // DeploymentModel()/the link log would claim model B while the wire is unscoped. + return key.All(b => b == 0) ? null : key; + } } diff --git a/src/dapps/dapps.meshcore/MeshCoreClient.cs b/src/dapps/dapps.meshcore/MeshCoreClient.cs index 6c51b13..4d14e4a 100644 --- a/src/dapps/dapps.meshcore/MeshCoreClient.cs +++ b/src/dapps/dapps.meshcore/MeshCoreClient.cs @@ -26,6 +26,7 @@ public sealed class MeshCoreClient : IAsyncDisposable public const byte CMD_SET_RADIO_TX_POWER = 0x0C; public const byte CMD_GET_CHANNEL = 0x1F; public const byte CMD_SET_CHANNEL = 0x20; + public const byte CMD_SET_FLOOD_SCOPE_KEY = 0x36; // v8+: transient (RAM) flood-scope override public const byte CMD_SEND_CHANNEL_DATA = 0x3E; public const ushort DATA_TYPE_DEV = 0xFFFF; @@ -233,6 +234,34 @@ public async Task SetChannelAsync(byte index, string name, byte[] secret16, Canc await ExchangeAsync(p, [RSP_OK], TimeSpan.FromSeconds(3), ct); } + /// Set (or clear) the transient flood-scope key (Companion CMD 0x36, v8+), + /// deployment model B. The key is never transmitted - the radio HMACs it to a 2-byte + /// transport code and marks our floods scoped, so repeaters/room-servers without a + /// matching region silently drop them. null/empty/all-zero + /// clears the scope (unscoped, model A). RAM-only: reset on radio reboot, so this is + /// re-applied on every (re)configure. Returns false (rather than throwing) if the + /// firmware rejects the command - i.e. it's too old to support scoping. + public async Task SetFloodScopeAsync(byte[]? key16, CancellationToken ct) + { + if (key16 is not null && key16.Length != 0 && key16.Length != 16) + throw new ArgumentException("flood-scope key must be 16 bytes", nameof(key16)); + bool scoped = key16 is { Length: 16 } && key16.Any(b => b != 0); + byte[] p; + if (scoped) + { + p = new byte[18]; + p[0] = CMD_SET_FLOOD_SCOPE_KEY; + p[1] = 0x00; // selector 0 = set/clear scope key + key16!.CopyTo(p, 2); + } + else + { + p = [CMD_SET_FLOOD_SCOPE_KEY, 0x00]; // len 2 = clear the override -> unscoped + } + var resp = await ExchangeAsync(p, [RSP_OK, RSP_ERR], TimeSpan.FromSeconds(3), ct); + return resp[0] == RSP_OK; + } + public async Task GetChannelAsync(byte index, CancellationToken ct) { var resp = await ExchangeAsync([CMD_GET_CHANNEL, index], [RSP_CHANNEL_INFO], TimeSpan.FromSeconds(3), ct); diff --git a/src/dapps/dapps.meshcore/MeshCoreLink.cs b/src/dapps/dapps.meshcore/MeshCoreLink.cs index 24a8f57..0923565 100644 --- a/src/dapps/dapps.meshcore/MeshCoreLink.cs +++ b/src/dapps/dapps.meshcore/MeshCoreLink.cs @@ -24,6 +24,7 @@ public enum LinkState { Down, Healthy, Resetting, Failed } private readonly MeshCoreBearerOptions _opts; private readonly RegionPreset _region; private readonly byte[] _psk; + private readonly byte[]? _floodScope; private readonly ILogger _log; private readonly SemaphoreSlim _gate = new(1, 1); @@ -44,6 +45,7 @@ public MeshCoreLink(MeshCoreBearerOptions opts, ILogger log) _log = log; _region = opts.ResolveRegion(); _psk = opts.ResolvePsk(); + _floodScope = opts.ResolveFloodScopeKey(); } public async Task StartAsync(CancellationToken ct) @@ -60,6 +62,7 @@ private async Task OpenAndConfigureAsync(CancellationToken ct) client.PacketHeard += (len, snr) => PacketHeard?.Invoke(len, snr); SelfInfo self; + bool scopeApplied = false; try { client.Open(); @@ -68,6 +71,17 @@ private async Task OpenAndConfigureAsync(CancellationToken ct) await client.SetTxPowerAsync(Math.Min(_opts.TxPowerDbm, _region.MaxPowerDbm), ct); await client.SetNameAsync(_opts.NodeName, ct); await client.SetChannelAsync(_opts.ChannelIndex, _opts.ChannelName, _psk, ct); + // Deployment model B: apply (or clear) the flood-scope override every configure + // - it's RAM-only and reset on the radio reboots our watchdog triggers. + scopeApplied = await client.SetFloodScopeAsync(_floodScope, ct); + if (_floodScope is not null) + { + if (scopeApplied) + _log.LogInformation("MeshCore: flood-scope applied (model B) - floods contained to nodes sharing the scope"); + else + _log.LogWarning("MeshCore: radio rejected the flood-scope key - firmware too old to scope floods; " + + "traffic will flood UNSCOPED (model A) despite MeshCoreFloodScopeKey being set"); + } self = await client.AppStartAsync(_opts.AppName, ct); } catch @@ -81,10 +95,15 @@ private async Task OpenAndConfigureAsync(CancellationToken ct) _client = client; Self = self; State = LinkState.Healthy; + // Report the EFFECTIVE model: if scoping (B) was requested but the radio rejected + // the key, the traffic is actually unscoped, so don't headline it as B. + var model = _opts.DeploymentModel(); + if (model.StartsWith("B", StringComparison.Ordinal) && !scopeApplied) + model = "A (unscoped - firmware rejected the scope key)"; _log.LogInformation( - "MeshCore link up: {0} {1:0.000}MHz/{2:0.#}kHz/SF{3}/CR{4} ch[{5}]='{6}' node='{7}' txp={8}dBm", + "MeshCore link up: {0} {1:0.000}MHz/{2:0.#}kHz/SF{3}/CR{4} ch[{5}]='{6}' node='{7}' txp={8}dBm model={9}", self.PublicKeyHex[..12], self.FreqMhz, self.BwKhz, self.Sf, self.Cr, - _opts.ChannelIndex, _opts.ChannelName, _opts.NodeName, self.TxPower); + _opts.ChannelIndex, _opts.ChannelName, _opts.NodeName, self.TxPower, model); } private async Task WatchdogLoopAsync(CancellationToken ct) diff --git a/src/dapps/dapps.meshcore/README.md b/src/dapps/dapps.meshcore/README.md index 70e7940..7ec7274 100644 --- a/src/dapps/dapps.meshcore/README.md +++ b/src/dapps/dapps.meshcore/README.md @@ -41,29 +41,62 @@ evidence this is built on. The bearer is registered in `Program.cs` (before the AGW catch-all) and driven by `MeshCoreBearerService`. It is **inert (no serial port opened) unless `MeshCoreEnabled=true`**. -Configure via `DAPPS_MESHCORE_*` env vars (or the `systemoptions` table): +Configure via `DAPPS_MESH_CORE_*` env vars (or the `systemoptions` table): | Env var | Default | Meaning | |---|---|---| -| `DAPPS_MESHCORE_ENABLED` | `false` | turn the bearer on | -| `DAPPS_MESHCORE_PORT` | `/dev/ttyUSB0` | radio serial port | -| `DAPPS_MESHCORE_REGION` | `uk-test` | localisation preset (freq/BW/SF/CR + power cap) | -| `DAPPS_MESHCORE_TX_POWER_DBM` | `8` | TX power (capped by region) | -| `DAPPS_MESHCORE_CHANNEL_INDEX` | `1` | radio channel slot | -| `DAPPS_MESHCORE_CHANNEL_NAME` | `dapps` | channel label | -| `DAPPS_MESHCORE_CHANNEL_PSK` | `dapps-default-channel` | 32-char hex (16 B) or a passphrase | -| `DAPPS_MESHCORE_NODE_NAME` | `DAPPS` | radio advert name | -| `DAPPS_MESHCORE_AIRTIME_BUDGET_SECONDS_PER_HOUR` | `30` | governor budget | -| `DAPPS_MESHCORE_COMPRESS` | `true` | zstd-dict compression | -| `DAPPS_MESHCORE_CONGESTION_BACKOFF_FRACTION` | `0.5` | adaptive: refuse sends when channel occupancy ≥ this (0 disables) | -| `DAPPS_MESHCORE_LBT_GUARD_MS` | `400` | adaptive: listen-before-talk guard in ms (0 disables) | -| `DAPPS_MESHCORE_RELIABLE_DELIVERY` | `true` | end-to-end ACK + resend of unacked messages (#26) | +| `DAPPS_MESH_CORE_ENABLED` | `false` | turn the bearer on | +| `DAPPS_MESH_CORE_PORT` | `/dev/ttyUSB0` | radio serial port | +| `DAPPS_MESH_CORE_REGION` | `uk-test` | localisation preset (freq/BW/SF/CR + power cap); `custom` = model C | +| `DAPPS_MESH_CORE_CUSTOM_PRESET` | _(empty)_ | model C: when region=`custom`, `freq=868.4;bw=62.5;sf=8;cr=8;pwr=14` | +| `DAPPS_MESH_CORE_FLOOD_SCOPE_KEY` | _(empty)_ | model B: blank = unscoped; set to contain floods (see Containment) | +| `DAPPS_MESH_CORE_TX_POWER_DBM` | `8` | TX power (capped by region) | +| `DAPPS_MESH_CORE_CHANNEL_INDEX` | `1` | radio channel slot | +| `DAPPS_MESH_CORE_CHANNEL_NAME` | `dapps` | channel label | +| `DAPPS_MESH_CORE_CHANNEL_PSK` | `dapps-default-channel` | 32-char hex (16 B) or a passphrase | +| `DAPPS_MESH_CORE_NODE_NAME` | `DAPPS` | radio advert name | +| `DAPPS_MESH_CORE_AIRTIME_BUDGET_SECONDS_PER_HOUR` | `30` | governor budget | +| `DAPPS_MESH_CORE_COMPRESS` | `true` | zstd-dict compression | +| `DAPPS_MESH_CORE_CONGESTION_BACKOFF_FRACTION` | `0.5` | adaptive: refuse sends when channel occupancy ≥ this (0 disables) | +| `DAPPS_MESH_CORE_LBT_GUARD_MS` | `400` | adaptive: listen-before-talk guard in ms (0 disables) | +| `DAPPS_MESH_CORE_RELIABLE_DELIVERY` | `true` | end-to-end ACK + resend of unacked messages (#26) | Inbound is fully wired: received messages are decoded and delivered to `IBackhaulInbox` (DB + MQTT), sender derived from the in-band `LinkSourceCallsign`. Outbound is selected for routes carrying a MeshCore channel hint (`BackhaulRoute.MeshCoreChannel`); wiring that hint onto neighbour rows (`DbNeighbour`/`RouteBuilder`) is the remaining "usable from a configured neighbour" step (#155). +## Containment — deployment models (#24) + +**A private channel gives privacy (PSK) but NOT containment.** Channel messages flood +**unscoped** by default, and any same-preset **Repeater/Room-Server** relays them network-wide +*without needing the PSK* (source-verified: `simple_repeater` only decrypts to display, it +forwards regardless). So on the public UK-narrow preset our traffic can be carried across the +whole MeshCore net. Three deployment models, selectable per node: + +| Model | Config | Isolation | When | +|---|---|---|---| +| **A** unscoped public preset | `REGION=uk-narrow`, no scope key | none — public repeaters carry our floods everywhere | light traffic only; leans on the good-citizen controls (airtime governor, LBT, congestion backoff) | +| **B** scoped public preset | `REGION=uk-narrow` + `FLOOD_SCOPE_KEY=` | floods dropped by repeaters that don't share the scope | free public-repeater carriage between *our* scoped repeaters | +| **C** dedicated preset | `REGION=custom` + `CUSTOM_PRESET=freq=…;bw=…;sf=…;cr=…;pwr=…` | total physical isolation (own frequency/SF) | own infra; least config risk; the clean long-term option | + +**Model B — flood-scope**, source-verified against `companion-v1.16.0`: +- The bearer sends Companion `CMD_SET_FLOOD_SCOPE_KEY (0x36)` at every (re)configure. The 16-byte + key is **never transmitted** — the radio HMACs it to a 2-byte transport code and marks our + floods `ROUTE_TYPE_TRANSPORT_FLOOD`. A repeater/room-server that lacks a matching region with + flood permission **silently drops** the packet (`allowPacketForward` → `false`). Real containment, + not advisory. +- `FLOOD_SCOPE_KEY` is treated as a **public region name** and hashed `SHA256("#"+name)[..16]` — + the same derivation MeshCore uses — so you can carry traffic between your own repeaters by + configuring each with `region put ` (flood-allowed). A 32-char hex value is used verbatim. +- **Caveats (verified):** the key is a *routing label, not a secret* — anyone who knows the name + derives it. True `$`-private scope keys are **stubbed** in v1.16.x (non-functional). Scope is + **global per node**, not per-channel. Scoping is **RAM-only** (reset on reboot), so the bearer + re-applies it after every watchdog recovery. It's **off by default**. If the firmware is too old + to accept `0x36`, the bearer logs a warning and traffic stays unscoped (falls back to model A). +- Model B needs your **own scoped Repeater-firmware nodes** to carry traffic between non-adjacent + DAPPS nodes (a plain Companion doesn't repeat floods at all). + ## Soak harness `dapps.meshcore.soak` drives the **real** bearer classes through the real seam between two radios diff --git a/src/dapps/dapps.meshcore/Regions.cs b/src/dapps/dapps.meshcore/Regions.cs index d9a5980..2d4a59e 100644 --- a/src/dapps/dapps.meshcore/Regions.cs +++ b/src/dapps/dapps.meshcore/Regions.cs @@ -24,4 +24,65 @@ public static class Regions public static RegionPreset? Find(string name) => All.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + /// The region name that selects an operator-defined preset parsed from a + /// spec, rather than a baked entry in . + public const string CustomName = "custom"; + + /// + /// Build a one-off preset for a dedicated DAPPS frequency/SF (deployment model C — + /// physical isolation on the operator's own radio settings). Spec is KV pairs + /// separated by ';' or ',', e.g. freq=868.4;bw=62.5;sf=8;cr=8;pwr=14 where + /// freq is MHz, bw is kHz, and pwr is the max TX power in dBm the bearer will clamp + /// to. All five fields are required; ranges are validated so a fat-fingered value + /// can't push the radio somewhere illegal or nonsensical. Regulatory compliance for + /// a custom frequency/power is the operator's responsibility. + /// + public static RegionPreset ParseCustom(string spec) + { + var kv = ParseKv(spec); + double freq = ReqDouble(kv, "freq", 100.0, 2000.0); // MHz, spans the LoRa ISM bands + double bw = ReqDouble(kv, "bw", 1.0, 1000.0); // kHz + byte sf = (byte)ReqInt(kv, "sf", 5, 12); + byte cr = (byte)ReqInt(kv, "cr", 5, 8); // 4/5..4/8 + byte pwr = (byte)ReqInt(kv, "pwr", 1, 30); // max dBm + return new RegionPreset(CustomName, freq, bw, sf, cr, pwr, + "Operator-defined dedicated DAPPS preset (deployment model C) - own frequency/SF for physical isolation."); + } + + private static Dictionary ParseKv(string spec) + { + if (string.IsNullOrWhiteSpace(spec)) + throw new ArgumentException("custom MeshCore preset is empty; set a spec like 'freq=868.4;bw=62.5;sf=8;cr=8;pwr=14'"); + var kv = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in spec.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = part.IndexOf('='); + if (eq <= 0) throw new ArgumentException($"malformed custom-preset field '{part}' (expected key=value)"); + kv[part[..eq].Trim()] = part[(eq + 1)..].Trim(); + } + return kv; + } + + private static double ReqDouble(Dictionary kv, string key, double min, double max) + { + if (!kv.TryGetValue(key, out var raw)) + throw new ArgumentException($"custom preset missing required field '{key}'"); + if (!double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v)) + throw new ArgumentException($"custom preset field '{key}={raw}' is not a number"); + if (v < min || v > max) + throw new ArgumentException($"custom preset field '{key}={raw}' out of range [{min}..{max}]"); + return v; + } + + private static int ReqInt(Dictionary kv, string key, int min, int max) + { + if (!kv.TryGetValue(key, out var raw)) + throw new ArgumentException($"custom preset missing required field '{key}'"); + if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var v)) + throw new ArgumentException($"custom preset field '{key}={raw}' is not an integer"); + if (v < min || v > max) + throw new ArgumentException($"custom preset field '{key}={raw}' out of range [{min}..{max}]"); + return v; + } }