Files
Joseph Doherty b330faff03 mbproxy: cross-platform support — Linux/systemd alongside Windows
Make the service build, run, and install on Linux as a first-class
target while keeping the Windows Service + Event Log behaviour intact.

- Build: drop the hardcoded win-x64 RID — single-file publish now works
  for any RID. publish.ps1 gains -Rid; new publish.sh for Linux hosts.
- Diagnostics: DiagnosticSinkSelector picks the Error+ sink per host —
  Windows Event Log under the SCM, local syslog under systemd
  (Serilog.Sinks.SyslogMessages), none for interactive runs. The
  EventLog truncation helper is extracted so it is testable cross-OS.
- Host: Program.cs registers AddSystemd() alongside AddWindowsService().
- Config: a RID-conditioned appsettings template ships Windows or Unix
  paths; both templates are schema-validated by a test.
- Install: systemd unit (Type=exec) plus install.sh / uninstall.sh.
  Also fixes two cross-platform bugs found while testing: install.ps1
  and uninstall.ps1 used New-EventLog / Remove-EventLog (absent in
  PowerShell 7), and the E2E sim launcher hardcoded Windows venv paths.
- Docs updated across README, CLAUDE.md, and docs/ for dual-platform.

413 tests pass on Windows; 374 (all non-simulator) on Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:41:59 -04:00

206 lines
8.8 KiB
PowerShell

#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'..."
# .NET API, not New-EventLog: the *-EventLog cmdlets exist only in Windows
# PowerShell 5.1, not PowerShell 7+. This call is symmetric with the
# SourceExists check above and works on every PowerShell edition.
[System.Diagnostics.EventLog]::CreateEventSource('mbproxy', '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/"