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
@@ -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();
}
}
}