mbproxy: fix dashboard review findings, add named BCD tags + fleet config

Reviewed the new SignalR dashboard and fixed its two top findings: a stored XSS on the connection-detail page (unescaped tag name / direction / timestamp rendered into innerHTML) and FC03/FC04 cache hits bypassing the debug-view capture, which left cached tags frozen while their age climbed. Also adds an optional human-friendly Name to BCD tags surfaced on the debug view, and loads the real fleet config from tags.txt (12 named BCD tags, PLC Z28061) so the published appsettings.json is deploy-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-16 03:39:39 -04:00
parent e719dd51c1
commit 554b05d28c
27 changed files with 964 additions and 83 deletions
@@ -54,6 +54,29 @@ public sealed class BcdTagMapBuilderTests
t32.Width.ShouldBe((byte)32);
}
[Fact]
public void Build_CarriesOptionalTagName_IntoResolvedMap()
{
// The optional human-friendly Name flows from config options through to the
// resolved BcdTag; an omitted Name resolves to null.
var global = new BcdTagListOptions
{
Global =
[
new BcdTagOptions { Address = 1548, Width = 16, Name = "Left AirSP" },
new BcdTagOptions { Address = 1080, Width = 32 },
],
};
var result = BcdTagMapBuilder.Build(global, perPlc: null);
result.Errors.ShouldBeEmpty();
result.Map.TryGet(1548, out var named).ShouldBeTrue();
named.Name.ShouldBe("Left AirSP");
result.Map.TryGet(1080, out var unnamed).ShouldBeTrue();
unnamed.Name.ShouldBeNull();
}
[Fact]
public void Build_PerPlcAdd_AppendsToGlobal()
{
@@ -45,6 +45,7 @@ public sealed class MbproxyOptionsBindingTests
{
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
["Mbproxy:BcdTags:Global:0:Width"] = "16",
["Mbproxy:BcdTags:Global:0:Name"] = "Left AirSP",
["Mbproxy:BcdTags:Global:1:Address"] = "1080",
["Mbproxy:BcdTags:Global:1:Width"] = "32",
});
@@ -52,8 +53,10 @@ public sealed class MbproxyOptionsBindingTests
options.BcdTags.Global.Count.ShouldBe(2);
options.BcdTags.Global[0].Address.ShouldBe((ushort)1072);
options.BcdTags.Global[0].Width.ShouldBe((byte)16);
options.BcdTags.Global[0].Name.ShouldBe("Left AirSP");
options.BcdTags.Global[1].Address.ShouldBe((ushort)1080);
options.BcdTags.Global[1].Width.ShouldBe((byte)32);
options.BcdTags.Global[1].Name.ShouldBeNull("Name is optional — an omitted entry binds to null");
}
// -------------------------------------------------------------------------
@@ -542,4 +542,75 @@ public sealed class ResponseCacheMultiplexerTests
l.Stop();
}
}
[Fact]
public async Task CacheHit_RecordsServedRead_IntoArmedDebugCapture()
{
// C3 regression guard: a cache hit bypasses the BCD pipeline, so without the
// cache-entry observation replay the connection-detail debug view would freeze
// for the whole TTL on a cached tag. A hit must re-record the served read into
// the (armed) capture so the debug view reflects what the client receives.
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 };
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var tag = BcdTag.Create(100, 16, cacheTtlMs: 5000);
// A detail-page viewer has armed this PLC's debug-view capture.
var capture = new TagValueCapture([tag]);
capture.Arm();
var frozen = new[] { tag }.ToDictionary(t => t.Address).ToFrozenDictionary();
var ctx = new PerPlcContext
{
PlcName = "PLC1",
TagMap = new BcdTagMap(frozen),
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Cache = cache,
Capture = capture,
};
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// First read — cache miss. The pipeline records the observation and the
// entry is stored with the per-tag observations attached.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
var afterMiss = capture.Snapshot().Single(o => o.Address == 100);
afterMiss.UpdatedAtUtc.ShouldNotBeNull("the cache-miss read must record an observation");
afterMiss.DecodedValue.ShouldBe(1234);
afterMiss.RawLow.ShouldBe((ushort)0x1234);
// Clear the capture's slots (models the debug view holding no fresh data),
// then re-arm. Only the cache-hit replay can repopulate slot 100 now — the
// backend is not contacted again.
capture.Disarm();
capture.Arm();
capture.Snapshot().Single(o => o.Address == 100).UpdatedAtUtc
.ShouldBeNull("Disarm must clear the slot");
// Second read — cache hit. No backend round-trip; the pipeline is bypassed.
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
var afterHit = capture.Snapshot().Single(o => o.Address == 100);
afterHit.UpdatedAtUtc.ShouldNotBeNull(
"a cache hit must re-record the served read so the debug view does not freeze");
afterHit.DecodedValue.ShouldBe(1234, "the replayed observation carries the decoded value");
afterHit.RawLow.ShouldBe((ushort)0x1234, "the replayed observation carries the raw BCD nibbles");
afterHit.Direction.ShouldBe(CaptureDirection.Read);
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
}
@@ -43,6 +43,27 @@ public sealed class TagValueCaptureTests
slot.UpdatedAtUtc.ShouldNotBeNull();
}
[Fact]
public void Snapshot_CarriesTagName_FromConfiguredTag()
{
// The capture surfaces a BCD tag's optional friendly name on every
// observation — placeholder rows (no traffic) and recorded values alike —
// so the debug view can label rows. An unnamed tag surfaces a null Name.
var capture = new TagValueCapture(
[
BcdTag.Create(100, 16, name: "Left AirSP"),
BcdTag.Create(200, 32),
]);
var before = capture.Snapshot();
before.Single(o => o.Address == 100).Name.ShouldBe("Left AirSP");
before.Single(o => o.Address == 200).Name.ShouldBeNull();
capture.Arm();
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
capture.Snapshot().Single(o => o.Address == 100).Name.ShouldBe("Left AirSP");
}
[Fact]
public void Armed_Record_UnknownAddress_IsIgnored()
{