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
+31 -33
View File
@@ -25,21 +25,33 @@
// Remove — removes specific addresses from the effective set for that PLC.
// Effective set = (Global Add) Remove, resolved per PDU.
"BcdTags": {
// Fleet-wide BCD tag list from tags.txt — applies to every PLC.
// Address = Modbus PDU-decimal = (4xxxx Modbus address 40001), which also
// equals the DirectLOGIC V-memory address converted octal → decimal
// (e.g. 41549 / V3014 → 41549 40001 = 1548 ; octal 3014 = 1548).
// A 32-bit tag is ONE entry at its low/base address; it covers Address &
// Address+1 (CDAB: low word at Address, high word at Address+1).
// Name (optional) is a human-friendly label shown on the connection-detail
// debug view; it has no effect on rewriting. tags.txt's "Data Direction" is
// informational — the proxy rewrites BCD on whichever FC touches the address.
// CacheTtlMs (optional, per entry) opts a tag into the Phase-11 response cache;
// omitted / 0 = uncached (the default for every tag).
"Global": [
// V2000 (octal) = decimal address 1024. 16-bit BCD counter.
{ "Address": 1024, "Width": 16 },
// ── 16-bit setpoints — BCD16, HMI-written ────────────────────────────
{ "Address": 1536, "Width": 16, "Name": "Left ArgonSP" }, // 41537
{ "Address": 1539, "Width": 16, "Name": "Right ArgonSP" }, // 41540
{ "Address": 1544, "Width": 16, "Name": "Left ChlorineSP" }, // 41545 · V3010
{ "Address": 1545, "Width": 16, "Name": "Right ChlorineSP" }, // 41546 · V3011
{ "Address": 1546, "Width": 16, "Name": "Left HydrogenSP" }, // 41547 · V3012
{ "Address": 1547, "Width": 16, "Name": "Right HydrogenSP" }, // 41548 · V3013
{ "Address": 1548, "Width": 16, "Name": "Left AirSP" }, // 41549 · V3014
{ "Address": 1549, "Width": 16, "Name": "Right AirSP" }, // 41550 · V3015
// V2040 (octal) = decimal address 1056. 32-bit BCD total at 1056/1057.
{ "Address": 1056, "Width": 32 },
// V2100 (octal) = decimal address 1088. 16-bit BCD setpoint.
//
// Phase 11: CacheTtlMs (optional) opts this tag into the response cache. With
// CacheTtlMs > 0 set, upstream clients reading this register will see values up
// to CacheTtlMs MILLISECONDS OLD — explicit acknowledgement of the staleness
// window is required by enabling it. Default (omitted or 0) = cache disabled
// for this tag. The cache is OFF by default for every tag.
{ "Address": 1088, "Width": 16 /* , "CacheTtlMs": 1000 */ }
// ── 32-bit runtimes — BCD32, read; CDAB pair spans Address & Address+1 ─
{ "Address": 4616, "Width": 32, "Name": "MTA Runtime Left (min)" }, // 44617/44618 · V11010
{ "Address": 4618, "Width": 32, "Name": "MTA Runtime Right (min)" }, // 44619/44620 · V11012
{ "Address": 4626, "Width": 32, "Name": "FRR Runtime Left (min)" }, // 44627/44628 · V11022
{ "Address": 4628, "Width": 32, "Name": "FRR Runtime Right (min)" } // 44629/44630 · V11024
]
},
@@ -52,26 +64,12 @@
// port will cause a backend connect failure and an immediate upstream disconnect.
"Plcs": [
{
"Name": "Line1-Mixer", // Human-readable name (shown on status page and in logs)
"ListenPort": 5020, // Port the proxy listens on (upstream clients connect here)
"Host": "10.0.1.1", // PLC IP address or hostname
"Port": 502, // PLC Modbus TCP port (almost always 502)
"BcdTags": {
// Additional 32-bit tag specific to this PLC only.
"Add": [
{ "Address": 1200, "Width": 32 }
],
// Remove address 1056 from the Global list for this PLC
// (this mixer doesn't use the 32-bit BCD total).
"Remove": [ 1056 ]
}
},
{
"Name": "Line1-Conveyor",
"ListenPort": 5021,
"Host": "10.0.1.2",
"Port": 502
// No BcdTags override — uses the Global set as-is.
"Name": "Z28061", // Human-readable name (shown on status page and in logs)
"ListenPort": 5020, // Port the proxy listens on (upstream clients connect here)
"Host": "10.210.192.5", // PLC IP address or hostname
"Port": 502 // PLC Modbus TCP port (almost always 502)
// No BcdTags override — uses the Global set as-is. Per-PLC overrides are
// available: "BcdTags": { "Add": [ ... ], "Remove": [ ... ] }.
}
// Add one entry per PLC. Ports must be unique per host. Typical fleet: 54 PLCs.
],
@@ -29,21 +29,33 @@
// Remove — removes specific addresses from the effective set for that PLC.
// Effective set = (Global Add) Remove, resolved per PDU.
"BcdTags": {
// Fleet-wide BCD tag list from tags.txt — applies to every PLC.
// Address = Modbus PDU-decimal = (4xxxx Modbus address 40001), which also
// equals the DirectLOGIC V-memory address converted octal → decimal
// (e.g. 41549 / V3014 → 41549 40001 = 1548 ; octal 3014 = 1548).
// A 32-bit tag is ONE entry at its low/base address; it covers Address &
// Address+1 (CDAB: low word at Address, high word at Address+1).
// Name (optional) is a human-friendly label shown on the connection-detail
// debug view; it has no effect on rewriting. tags.txt's "Data Direction" is
// informational — the proxy rewrites BCD on whichever FC touches the address.
// CacheTtlMs (optional, per entry) opts a tag into the Phase-11 response cache;
// omitted / 0 = uncached (the default for every tag).
"Global": [
// V2000 (octal) = decimal address 1024. 16-bit BCD counter.
{ "Address": 1024, "Width": 16 },
// ── 16-bit setpoints — BCD16, HMI-written ────────────────────────────
{ "Address": 1536, "Width": 16, "Name": "Left ArgonSP" }, // 41537
{ "Address": 1539, "Width": 16, "Name": "Right ArgonSP" }, // 41540
{ "Address": 1544, "Width": 16, "Name": "Left ChlorineSP" }, // 41545 · V3010
{ "Address": 1545, "Width": 16, "Name": "Right ChlorineSP" }, // 41546 · V3011
{ "Address": 1546, "Width": 16, "Name": "Left HydrogenSP" }, // 41547 · V3012
{ "Address": 1547, "Width": 16, "Name": "Right HydrogenSP" }, // 41548 · V3013
{ "Address": 1548, "Width": 16, "Name": "Left AirSP" }, // 41549 · V3014
{ "Address": 1549, "Width": 16, "Name": "Right AirSP" }, // 41550 · V3015
// V2040 (octal) = decimal address 1056. 32-bit BCD total at 1056/1057.
{ "Address": 1056, "Width": 32 },
// V2100 (octal) = decimal address 1088. 16-bit BCD setpoint.
//
// Phase 11: CacheTtlMs (optional) opts this tag into the response cache. With
// CacheTtlMs > 0 set, upstream clients reading this register will see values up
// to CacheTtlMs MILLISECONDS OLD — explicit acknowledgement of the staleness
// window is required by enabling it. Default (omitted or 0) = cache disabled
// for this tag. The cache is OFF by default for every tag.
{ "Address": 1088, "Width": 16 /* , "CacheTtlMs": 1000 */ }
// ── 32-bit runtimes — BCD32, read; CDAB pair spans Address & Address+1 ─
{ "Address": 4616, "Width": 32, "Name": "MTA Runtime Left (min)" }, // 44617/44618 · V11010
{ "Address": 4618, "Width": 32, "Name": "MTA Runtime Right (min)" }, // 44619/44620 · V11012
{ "Address": 4626, "Width": 32, "Name": "FRR Runtime Left (min)" }, // 44627/44628 · V11022
{ "Address": 4628, "Width": 32, "Name": "FRR Runtime Right (min)" } // 44629/44630 · V11024
]
},
@@ -56,26 +68,12 @@
// port will cause a backend connect failure and an immediate upstream disconnect.
"Plcs": [
{
"Name": "Line1-Mixer", // Human-readable name (shown on status page and in logs)
"ListenPort": 5020, // Port the proxy listens on (upstream clients connect here)
"Host": "10.0.1.1", // PLC IP address or hostname
"Port": 502, // PLC Modbus TCP port (almost always 502)
"BcdTags": {
// Additional 32-bit tag specific to this PLC only.
"Add": [
{ "Address": 1200, "Width": 32 }
],
// Remove address 1056 from the Global list for this PLC
// (this mixer doesn't use the 32-bit BCD total).
"Remove": [ 1056 ]
}
},
{
"Name": "Line1-Conveyor",
"ListenPort": 5021,
"Host": "10.0.1.2",
"Port": 502
// No BcdTags override — uses the Global set as-is.
"Name": "Z28061", // Human-readable name (shown on status page and in logs)
"ListenPort": 5020, // Port the proxy listens on (upstream clients connect here)
"Host": "10.210.192.5", // PLC IP address or hostname
"Port": 502 // PLC Modbus TCP port (almost always 502)
// No BcdTags override — uses the Global set as-is. Per-PLC overrides are
// available: "BcdTags": { "Add": [ ... ], "Remove": [ ... ] }.
}
// Add one entry per PLC. Ports must be unique per host. Typical fleet: 54 PLCs.
],
+23
View File
@@ -10,6 +10,10 @@
framework-dependent\ ~1.6 MB — requires the .NET 10 + ASP.NET Core runtime
preinstalled on the target.
Each folder also receives a current appsettings.json — the platform-appropriate
install template (Windows or Linux, selected by -Rid) — so every publish-out
flavour is a complete, deployable folder.
The runtime is selected with -Rid (default win-x64). The binary is Mbproxy.exe on
Windows RIDs and Mbproxy on Linux/macOS RIDs.
@@ -70,6 +74,25 @@ Write-Host "`n=== Publishing framework-dependent ($Rid, ~1.6 MB) ===" -Foregroun
& dotnet publish $csproj -c Release -r $Rid -p:SelfContained=false -p:PublishSingleFile=true -o $frameworkDependentOut --nologo
if ($LASTEXITCODE -ne 0) { throw "framework-dependent publish failed (exit $LASTEXITCODE)" }
# ── Ship the platform-appropriate config template as appsettings.json ──────────
# dotnet publish already copies it via the Mbproxy.csproj <Content> link, but that
# link uses PreserveNewest — an incremental (non-Clean) run can leave a stale
# config behind. Copy it explicitly so every publish-out flavour is guaranteed a
# current appsettings.json, and so the config's source is obvious.
$configTemplate = if ($Rid -like 'win-*') {
Join-Path $repoRoot 'install\mbproxy.config.template.json'
} else {
Join-Path $repoRoot 'install\mbproxy.linux.config.template.json'
}
if (-not (Test-Path $configTemplate)) { throw "Cannot find config template: $configTemplate" }
Write-Host "`n=== Config (appsettings.json) ===" -ForegroundColor Cyan
foreach ($flavour in 'self-contained','framework-dependent') {
$dest = Join-Path $OutputDir "$flavour\appsettings.json"
Copy-Item -LiteralPath $configTemplate -Destination $dest -Force
Write-Host (" {0,-22} <- {1}" -f $flavour, $configTemplate)
}
function Format-Size {
param([long]$Bytes)
if ($Bytes -ge 1MB) { '{0:N1} MB' -f ($Bytes / 1MB) }
+26
View File
@@ -10,6 +10,10 @@
# framework-dependent/ ~1.6 MB — requires the .NET 10 + ASP.NET Core runtime
# preinstalled on the target.
#
# Each folder also receives a current appsettings.json — the platform-appropriate
# install template (Windows or Linux, selected by -r RID) — so every publish-out
# flavour is a complete, deployable folder.
#
# Both builds use the Release configuration and inherit the publish settings in
# src/Mbproxy/Mbproxy.csproj (those settings are gated on an explicit RID, which
# is supplied here). The framework-dependent build overrides SelfContained=false.
@@ -68,6 +72,28 @@ echo "=== Publishing framework-dependent ($rid, ~1.6 MB) ==="
dotnet publish "$csproj" -c Release -r "$rid" \
-p:SelfContained=false -p:PublishSingleFile=true -o "$framework_dependent_out" --nologo
# Ship the platform-appropriate config template as appsettings.json.
# dotnet publish already copies it via the Mbproxy.csproj <Content> link, but that
# link uses PreserveNewest — an incremental (non-clean) run can leave a stale config
# behind. Copy it explicitly so every publish-out flavour is guaranteed a current
# appsettings.json, and so the config's source is obvious.
if [[ "$rid" == win-* ]]; then
config_template="$repo_root/install/mbproxy.config.template.json"
else
config_template="$repo_root/install/mbproxy.linux.config.template.json"
fi
if [[ ! -f "$config_template" ]]; then
echo "Cannot find config template: $config_template" >&2
exit 1
fi
echo
echo "=== Config (appsettings.json) ==="
for flavour in self-contained framework-dependent; do
cp -f "$config_template" "$output_dir/$flavour/appsettings.json"
printf ' %-22s <- %s\n' "$flavour" "$config_template"
done
echo
echo "=== Result ($rid) ==="
for flavour in self-contained framework-dependent; do