mbproxy: initial commit through Phase 9 (TxId multiplexing)

Adds the mbproxy service end-to-end. Phases 00-08 implement the
production-ready single-listener / 1:1-backend transparent Modbus TCP
proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260
fleet. Phase 9 replaces the connection layer with a single backend
socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's
4-concurrent-client cap as an operational ceiling.

Phase 9 additions of note:
- PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap
- InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing
  for Phase 10 read coalescing — do not collapse to a single field)
- Per-request watchdog: surfaces Modbus exception 0x0B to upstream
  on BackendRequestTimeoutMs, defending against lost responses,
  dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed-
  request bug (its ServerRequestHandler.last_pdu state race)
- Status DTO + HTML gain inFlight / maxInFlight / txIdWraps /
  disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md)

Tests: 263 unit + 38 E2E. Multiplexer correctness under truly
concurrent backend traffic is proved against a stub backend in
PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus
3.13's single-PDU framer stays in known-good mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-14 01:49:35 -04:00
parent 2e937228a0
commit 56eee3c563
105 changed files with 18430 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Installs the mbproxy service on a Windows host.
.DESCRIPTION
Copies the published binaries to InstallPath, registers the Windows Service,
sets failure-recovery actions, creates the data directory and log folder,
copies the config template if no config exists, and registers the Windows Event
Log source. Re-running this script on an already-installed service is safe
(idempotent): binaries are updated, service config is refreshed, and the service
is restarted if it was running.
.PARAMETER PublishOutput
Path to the directory produced by 'dotnet publish'. Must contain Mbproxy.exe.
.PARAMETER InstallPath
Destination directory for the service binaries.
Default: C:\Program Files\Mbproxy
.PARAMETER ServiceName
Windows Service name (used with sc.exe).
Default: mbproxy
.PARAMETER DisplayName
Display name shown in services.msc.
Default: Mbproxy — Modbus TCP BCD proxy
.PARAMETER Account
Service account (e.g. LocalSystem, NT AUTHORITY\LocalService, or a gMSA UPN).
Default: LocalSystem
.PARAMETER Start
If specified, starts the service immediately after install.
.EXAMPLE
.\install.ps1 -PublishOutput C:\build\publish -Start
.EXAMPLE
.\install.ps1 -PublishOutput \\fileserver\mbproxy\publish -ServiceName mbproxy-line2 -Start
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PublishOutput,
[string]$InstallPath = 'C:\Program Files\Mbproxy',
[string]$ServiceName = 'mbproxy',
[string]$DisplayName = 'Mbproxy — Modbus TCP BCD proxy',
[string]$Account = 'LocalSystem',
[switch]$Start
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── 0. Pre-flight checks ──────────────────────────────────────────────────────────────────
if (-not (Test-Path (Join-Path $PublishOutput 'Mbproxy.exe'))) {
Write-Error "Mbproxy.exe not found in '$PublishOutput'. Run 'dotnet publish' first."
}
Write-Host "Installing mbproxy service..." -ForegroundColor Cyan
Write-Host " PublishOutput : $PublishOutput"
Write-Host " InstallPath : $InstallPath"
Write-Host " ServiceName : $ServiceName"
Write-Host " Account : $Account"
# ── 1. Stop the service if it's running ─────────────────────────────────────────────────
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existingService -and $existingService.Status -eq 'Running') {
Write-Host "Stopping running service '$ServiceName'..."
sc.exe stop $ServiceName | Out-Null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 500
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
} while ($svc -and $svc.Status -ne 'Stopped' -and [DateTime]::UtcNow -lt $deadline)
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Warning "Service did not stop within 30 s — proceeding anyway."
}
}
# ── 2. Copy binaries ─────────────────────────────────────────────────────────────────────
if (-not (Test-Path $InstallPath)) {
Write-Host "Creating install directory '$InstallPath'..."
New-Item -ItemType Directory -Force $InstallPath | Out-Null
}
# Preserve any existing appsettings.json (operator may have customised it).
$destConfig = Join-Path $InstallPath 'appsettings.json'
$hasExistingConfig = Test-Path $destConfig
Write-Host "Copying binaries from '$PublishOutput' to '$InstallPath'..."
# Exclude appsettings.json if it already exists at the destination.
Get-ChildItem -Path $PublishOutput -File | ForEach-Object {
$dest = Join-Path $InstallPath $_.Name
if ($_.Name -eq 'appsettings.json' -and $hasExistingConfig) {
Write-Host " Preserving existing appsettings.json at '$destConfig'"
} else {
Copy-Item -Path $_.FullName -Destination $dest -Force
}
}
# ── 3. Register (or update) the Windows Service ──────────────────────────────────────────
$binPath = Join-Path $InstallPath 'Mbproxy.exe'
if ($existingService) {
Write-Host "Updating existing service '$ServiceName'..."
sc.exe config $ServiceName binPath= "`"$binPath`"" start= auto displayName= `"$DisplayName`" obj= $Account | Out-Null
} else {
Write-Host "Creating service '$ServiceName'..."
sc.exe create $ServiceName binPath= "`"$binPath`"" start= auto displayName= `"$DisplayName`" obj= $Account | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Error "sc.exe failed with exit code $LASTEXITCODE"
}
# ── 4. Set failure-recovery actions ──────────────────────────────────────────────────────
# Restart after 60 s on first and second failure; no action on third and subsequent.
Write-Host "Configuring failure-recovery actions..."
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/""/0 | Out-Null
# ── 5. Create data directory and log folder ──────────────────────────────────────────────
$dataDir = Join-Path $env:ProgramData 'mbproxy'
$logDir = Join-Path $dataDir 'logs'
if (-not (Test-Path $logDir)) {
Write-Host "Creating log directory '$logDir'..."
New-Item -ItemType Directory -Force $logDir | Out-Null
}
# Grant the service account write access to the data directory.
# For LocalSystem this is redundant (it already has full access), but explicit ACLs
# are good practice when using a restricted MSA/gMSA account.
if ($Account -notin @('LocalSystem', 'NT AUTHORITY\LocalSystem')) {
Write-Host "Setting ACLs on '$dataDir' for account '$Account'..."
try {
icacls $logDir /grant "${Account}:(OI)(CI)M" /T /Q | Out-Null
} catch {
Write-Warning "Could not set ACLs on '$logDir': $_"
}
}
# ── 6. Copy config template if no config exists ──────────────────────────────────────────
$configDest = Join-Path $dataDir 'appsettings.json'
if (-not (Test-Path $configDest)) {
$templateSrc = Join-Path $PSScriptRoot 'mbproxy.config.template.json'
if (Test-Path $templateSrc) {
Write-Host "Copying config template to '$configDest'..."
Copy-Item -Path $templateSrc -Destination $configDest -Force
} else {
Write-Warning "Config template not found at '$templateSrc' — create appsettings.json manually."
}
}
# ── 7. Register Windows Event Log source ─────────────────────────────────────────────────
if (-not [System.Diagnostics.EventLog]::SourceExists('mbproxy')) {
Write-Host "Registering Windows Event Log source 'mbproxy'..."
New-EventLog -Source 'mbproxy' -LogName 'Application'
} else {
Write-Host "Windows Event Log source 'mbproxy' already registered."
}
# ── 8. Optionally start the service ──────────────────────────────────────────────────────
if ($Start) {
Write-Host "Starting service '$ServiceName'..."
sc.exe start $ServiceName | Out-Null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 500
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
} while ($svc -and $svc.Status -ne 'Running' -and [DateTime]::UtcNow -lt $deadline)
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
Write-Host "Service '$ServiceName' is running." -ForegroundColor Green
} else {
Write-Warning "Service '$ServiceName' did not reach RUNNING state within 30 s. Check Event Log for errors."
}
}
Write-Host ""
Write-Host "Install complete." -ForegroundColor Green
Write-Host " Config : $configDest"
Write-Host " Logs : $logDir"
Write-Host " Binaries: $InstallPath"
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Edit '$configDest' to configure your PLC list and BCD tags."
Write-Host " 2. Start the service: sc.exe start $ServiceName"
Write-Host " 3. Check status page: http://localhost:8080/"
@@ -0,0 +1,155 @@
// mbproxy configuration template — copy to %ProgramData%\mbproxy\appsettings.json
// and edit before starting the service.
//
// The .NET configuration loader accepts // and /* */ comments in JSON files
// (JSONC semantics) when using the default Host.CreateApplicationBuilder path.
//
// IMPORTANT: This file is overwritten on each install ONLY if no appsettings.json
// already exists at the destination. An existing file is always preserved.
{
"Mbproxy": {
// ── Global BCD tag list ─────────────────────────────────────────────────────────────
// These tags apply to EVERY PLC by default.
// Each entry: Address (Modbus PDU address, decimal), Width (16 or 32 bits).
//
// Width 16 — one register holds 4 BCD digits (09999).
// Wire value 0x1234 decodes to decimal 1234.
//
// Width 32 — a CDAB-ordered register pair (Address = low word, Address+1 = high word).
// Decoded decimal = high * 10000 + low (DirectLOGIC CDAB word order).
//
// Per-PLC overrides (see Plcs[].BcdTags below):
// Add — appends extra tags beyond what Global defines, or overrides a
// Global entry's Width when the same Address appears in both.
// Remove — removes specific addresses from the effective set for that PLC.
// Effective set = (Global Add) Remove, resolved per PDU.
"BcdTags": {
"Global": [
// V2000 (octal) = decimal address 1024. 16-bit BCD counter.
{ "Address": 1024, "Width": 16 },
// 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.
{ "Address": 1088, "Width": 16 }
]
},
// ── PLC list ────────────────────────────────────────────────────────────────────────
// Each entry maps one upstream proxy port → one backend PLC.
// Upstream clients connect to ListenPort; the proxy forwards to Host:Port.
//
// IMPORTANT: H2-ECOM100 modules accept at most 4 simultaneous TCP connections.
// With the 1:1 upstream↔backend model, a fifth upstream client to the same proxy
// 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.
}
// Add one entry per PLC. Ports must be unique per host. Typical fleet: 54 PLCs.
],
// ── Admin port ──────────────────────────────────────────────────────────────────────
// Read-only HTTP status page.
// GET / → self-contained HTML (auto-refreshes every 5 s)
// GET /status.json → same data as JSON for monitoring scrapers
//
// Authentication is assumed at the network layer (trusted internal segment).
// Set to 0 to disable the admin endpoint.
"AdminPort": 8080,
// ── Connection timeouts ─────────────────────────────────────────────────────────────
"Connection": {
// Max time (ms) to wait for a TCP connect to the PLC backend.
// Each Polly retry attempt gets its own copy of this timeout.
"BackendConnectTimeoutMs": 3000,
// Max time (ms) to wait for the PLC to respond to a forwarded PDU.
// Non-idempotent FC06/FC16 writes are one-shot — the upstream client
// is disconnected immediately on timeout (no retry).
"BackendRequestTimeoutMs": 3000,
// Max time (ms) to wait for in-flight PDUs to complete during graceful shutdown
// (sc.exe stop / Windows Service stop signal). After this deadline the coordinator
// cancels remaining work and proceeds. Keep at or below the SCM wait-hint (30 s).
"GracefulShutdownTimeoutMs": 10000
},
// ── Resilience policies ─────────────────────────────────────────────────────────────
"Resilience": {
// Polly retry policy for backend TCP connect attempts.
// MaxAttempts: total connect tries (including the first).
// BackoffMs: delay between each attempt (must have MaxAttempts1 entries).
"BackendConnect": {
"MaxAttempts": 3,
"BackoffMs": [ 100, 500, 2000 ]
},
// Polly recovery policy for listener bind failures.
// If a PLC's listen port can't be bound (in-use, bad IP, transient OS error),
// the supervisor retries according to this schedule.
// InitialBackoffMs: backoff per step (first N retries).
// SteadyStateMs: backoff for all subsequent retries (runs indefinitely).
"ListenerRecovery": {
"InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ],
"SteadyStateMs": 30000
}
}
},
// ── Serilog ─────────────────────────────────────────────────────────────────────────────
// Structured log output. Default: Information level, rolling-file under ProgramData.
// The EventLogBridge writes Error+ events to the Windows Application Event Log
// automatically when the service runs under the SCM (not under dotnet run).
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
// Rolling log: one file per day, kept for 30 days.
// Survives uninstall — logs are archived to %ProgramData%\mbproxy.archived-<ts>\.
"path": "C:\\ProgramData\\mbproxy\\logs\\mbproxy-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
]
}
}
+138
View File
@@ -0,0 +1,138 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Removes the mbproxy Windows Service and its installed files.
.DESCRIPTION
Stops the service, deletes the service registration, removes the binary
install directory, and (unless -KeepConfig is specified) removes the data
directory. Log files are always preserved: they are moved to a timestamped
archive directory so post-uninstall diagnostics remain accessible.
.PARAMETER ServiceName
Windows Service name to uninstall.
Default: mbproxy
.PARAMETER InstallPath
Directory that was used as the install target.
Default: C:\Program Files\Mbproxy
.PARAMETER KeepConfig
If specified, leaves %ProgramData%\mbproxy\appsettings.json in place.
Logs are always preserved regardless of this flag.
.EXAMPLE
.\uninstall.ps1
.EXAMPLE
.\uninstall.ps1 -KeepConfig
#>
[CmdletBinding()]
param(
[string]$ServiceName = 'mbproxy',
[string]$InstallPath = 'C:\Program Files\Mbproxy',
[switch]$KeepConfig
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Write-Host "Uninstalling mbproxy service..." -ForegroundColor Cyan
Write-Host " ServiceName : $ServiceName"
Write-Host " InstallPath : $InstallPath"
Write-Host " KeepConfig : $KeepConfig"
# ── 1. Stop the service ───────────────────────────────────────────────────────────────────
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
if ($svc.Status -eq 'Running') {
Write-Host "Stopping service '$ServiceName'..."
sc.exe stop $ServiceName | Out-Null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 500
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
} while ($svc -and $svc.Status -ne 'Stopped' -and [DateTime]::UtcNow -lt $deadline)
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Warning "Service did not stop within 30 s — attempting force delete."
}
}
# ── 2. Delete the service ─────────────────────────────────────────────────────────────
Write-Host "Deleting service registration '$ServiceName'..."
sc.exe delete $ServiceName | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warning "sc.exe delete returned $LASTEXITCODE — the service entry may already be gone."
}
} else {
Write-Host "Service '$ServiceName' not found — skipping stop/delete."
}
# ── 3. Archive log files ─────────────────────────────────────────────────────────────────
# Logs are ALWAYS archived (never deleted) so post-uninstall crash diagnostics survive.
$dataDir = Join-Path $env:ProgramData 'mbproxy'
$logDir = Join-Path $dataDir 'logs'
if (Test-Path $logDir) {
$timestamp = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
$archiveName = "mbproxy.archived-$timestamp"
$archiveRoot = Join-Path $env:ProgramData $archiveName
$archiveLogs = Join-Path $archiveRoot 'logs'
Write-Host "Archiving logs to '$archiveLogs'..."
New-Item -ItemType Directory -Force $archiveLogs | Out-Null
Get-ChildItem -Path $logDir | ForEach-Object {
Move-Item -Path $_.FullName -Destination $archiveLogs -Force
}
Write-Host " Logs archived to: $archiveLogs" -ForegroundColor Yellow
}
# ── 4. Remove data directory ─────────────────────────────────────────────────────────────
if (Test-Path $dataDir) {
if ($KeepConfig) {
# Remove everything except appsettings.json; then remove the now-empty log dir.
Write-Host "Keeping config at '$dataDir\appsettings.json' (-KeepConfig specified)."
$logDirPath = Join-Path $dataDir 'logs'
if (Test-Path $logDirPath) {
Remove-Item -Recurse -Force $logDirPath -ErrorAction SilentlyContinue
}
} else {
Write-Host "Removing data directory '$dataDir'..."
Remove-Item -Recurse -Force $dataDir -ErrorAction SilentlyContinue
}
}
# ── 5. Remove binary install directory ───────────────────────────────────────────────────
if (Test-Path $InstallPath) {
Write-Host "Removing install directory '$InstallPath'..."
Remove-Item -Recurse -Force $InstallPath -ErrorAction SilentlyContinue
} else {
Write-Host "Install directory '$InstallPath' not found — skipping."
}
# ── 6. Remove Windows Event Log source ───────────────────────────────────────────────────
if ([System.Diagnostics.EventLog]::SourceExists('mbproxy')) {
Write-Host "Removing Windows Event Log source 'mbproxy'..."
try {
Remove-EventLog -Source 'mbproxy'
} catch {
Write-Warning "Could not remove Event Log source: $_"
}
} else {
Write-Host "Windows Event Log source 'mbproxy' not registered — skipping."
}
Write-Host ""
Write-Host "Uninstall complete." -ForegroundColor Green
if (Test-Path (Join-Path $env:ProgramData 'mbproxy.archived-*')) {
Write-Host "Archived logs can be found under: $env:ProgramData\mbproxy.archived-*" -ForegroundColor Yellow
}