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>
This commit is contained in:
Joseph Doherty
2026-05-15 09:41:59 -04:00
parent 0868613890
commit b330faff03
29 changed files with 1805 additions and 106 deletions
+4 -1
View File
@@ -165,7 +165,10 @@ if (-not (Test-Path $configDest)) {
if (-not [System.Diagnostics.EventLog]::SourceExists('mbproxy')) {
Write-Host "Registering Windows Event Log source 'mbproxy'..."
New-EventLog -Source 'mbproxy' -LogName 'Application'
# .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."
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# install.sh — install the mbproxy service on a Linux / systemd host.
#
# The Linux counterpart of install.ps1. Copies the published binary to
# /opt/mbproxy, seeds the config at /etc/mbproxy/appsettings.json (preserving any
# existing one), creates the log and bundle-cache directories and the mbproxy
# service account, installs the systemd unit, and enables + starts the service.
#
# Re-running on an already-installed service is safe (idempotent): the binary is
# refreshed, an existing /etc/mbproxy/appsettings.json is preserved, and the
# service is restarted.
#
# Usage:
# sudo ./install.sh [--publish-dir DIR] [--no-start]
#
# --publish-dir DIR directory containing the published Mbproxy binary.
# Default: <repo>/publish-out/self-contained
# --no-start install and enable the unit but do not start it.
#
set -euo pipefail
# ── 0. Settings ──────────────────────────────────────────────────────────────
SERVICE_NAME="mbproxy"
SERVICE_USER="mbproxy"
INSTALL_DIR="/opt/mbproxy"
CONFIG_DIR="/etc/mbproxy"
LOG_DIR="/var/log/mbproxy"
CACHE_DIR="/var/cache/mbproxy"
UNIT_DEST="/etc/systemd/system/${SERVICE_NAME}.service"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(dirname "$script_dir")"
publish_dir="${repo_root}/publish-out/self-contained"
start_service=1
while [[ $# -gt 0 ]]; do
case "$1" in
--publish-dir) publish_dir="$2"; shift 2 ;;
--no-start) start_service=0; shift ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
# ── 1. Pre-flight checks ─────────────────────────────────────────────────────
if [[ "$(id -u)" -ne 0 ]]; then
echo "install.sh must run as root (use sudo)." >&2
exit 1
fi
binary_src="${publish_dir}/Mbproxy"
if [[ ! -f "$binary_src" ]]; then
echo "Mbproxy binary not found at '${binary_src}'." >&2
echo "Run install/publish.sh first, or pass --publish-dir." >&2
exit 1
fi
unit_src="${script_dir}/mbproxy.service"
config_src="${publish_dir}/appsettings.json"
if [[ ! -f "$unit_src" ]]; then
echo "Unit file not found at '${unit_src}'." >&2
exit 1
fi
echo "Installing ${SERVICE_NAME} service..."
echo " Publish dir : ${publish_dir}"
echo " Install dir : ${INSTALL_DIR}"
echo " Config dir : ${CONFIG_DIR}"
# ── 2. Service account ───────────────────────────────────────────────────────
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
echo "Creating service account '${SERVICE_USER}'..."
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
else
echo "Service account '${SERVICE_USER}' already exists."
fi
# ── 3. Stop the service if running (so the binary can be replaced) ───────────
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping running service '${SERVICE_NAME}'..."
systemctl stop "$SERVICE_NAME"
fi
# ── 4. Directories ───────────────────────────────────────────────────────────
install -d -m 0755 "$INSTALL_DIR"
install -d -m 0755 "$CONFIG_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$LOG_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$CACHE_DIR"
# ── 5. Binary ────────────────────────────────────────────────────────────────
echo "Copying binary to '${INSTALL_DIR}/Mbproxy'..."
install -m 0755 "$binary_src" "${INSTALL_DIR}/Mbproxy"
# ── 6. Config (preserve an existing one) ─────────────────────────────────────
config_dest="${CONFIG_DIR}/appsettings.json"
if [[ -f "$config_dest" ]]; then
echo "Preserving existing config at '${config_dest}'."
elif [[ -f "$config_src" ]]; then
echo "Seeding config template to '${config_dest}'..."
install -m 0644 "$config_src" "$config_dest"
else
echo "WARNING: no appsettings.json in '${publish_dir}' — create '${config_dest}' manually." >&2
fi
# ── 7. systemd unit ──────────────────────────────────────────────────────────
echo "Installing systemd unit to '${UNIT_DEST}'..."
install -m 0644 "$unit_src" "$UNIT_DEST"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
# ── 8. Start ─────────────────────────────────────────────────────────────────
if [[ "$start_service" -eq 1 ]]; then
echo "Starting service '${SERVICE_NAME}'..."
systemctl start "$SERVICE_NAME"
sleep 1
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo "Service '${SERVICE_NAME}' is running."
else
echo "WARNING: service '${SERVICE_NAME}' did not reach active state." >&2
echo "Check: journalctl -u ${SERVICE_NAME} -e" >&2
fi
fi
echo ""
echo "Install complete."
echo " Config : ${config_dest}"
echo " Logs : ${LOG_DIR}"
echo " Binary : ${INSTALL_DIR}/Mbproxy"
echo ""
echo "Next steps:"
echo " 1. Edit '${config_dest}' to configure your PLC list and BCD tags."
echo " 2. Restart: sudo systemctl restart ${SERVICE_NAME}"
echo " 3. Logs: journalctl -u ${SERVICE_NAME} -f"
echo " 4. Status: http://localhost:8080/"
@@ -0,0 +1,255 @@
// mbproxy configuration template (Linux / systemd) — copy to /etc/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: install.sh overwrites this file at the destination ONLY if no
// appsettings.json already exists there. An existing file is always preserved.
//
// This is the Linux counterpart of mbproxy.config.template.json — identical except
// for the rolling-log path (/var/log/mbproxy) and a few platform notes. It is shipped
// as appsettings.json by a `dotnet publish -r linux-*` build.
{
"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.
//
// 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 */ }
]
},
// ── 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
// (systemctl stop → SIGTERM). After this deadline the coordinator cancels
// remaining work and proceeds. Keep at or below the unit's TimeoutStopSec.
"GracefulShutdownTimeoutMs": 10000,
// ── Keepalive / connection monitoring ───────────────────────────────────
// The DL205/DL260 ECOM does not emit TCP keepalives, so an idle backend
// socket can be silently dropped by a middlebox (switch, firewall, NAT)
// after 2-5 minutes. This section enables OS-level SO_KEEPALIVE on both
// backend and upstream sockets, and drives a periodic Modbus FC03 heartbeat
// on each idle backend socket so a dead path is detected before a real
// client request hits it. See docs/Architecture/Keepalive.md.
"Keepalive": {
// Master switch. false → no SO_KEEPALIVE and no heartbeat; the proxy
// behaves exactly as a pre-keepalive build.
"Enabled": true,
// SO_KEEPALIVE: idle time (ms) before the OS sends its first probe.
"TcpIdleTimeMs": 30000,
// SO_KEEPALIVE: interval (ms) between probes once the idle time elapses.
"TcpProbeIntervalMs": 5000,
// SO_KEEPALIVE: unanswered probes before the OS declares the socket dead.
"TcpProbeCount": 4,
// Backend heartbeat: after this much backend idle (ms) the proxy issues a
// synthetic FC03 qty=1 read to keep the path warm and prove the ECOM is
// still answering Modbus. Must be greater than BackendRequestTimeoutMs.
"BackendHeartbeatIdleMs": 30000,
// FC03 PDU address the heartbeat reads. 0 = V0, valid on DL205/DL260.
"BackendHeartbeatProbeAddress": 0
}
},
// ── 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
},
// Phase 10 — in-flight read coalescing.
//
// When two or more upstream clients (HMI / historian / engineering workstation /
// gateway) issue the SAME FC03 or FC04 read while a matching backend round-trip is
// already in flight, the proxy attaches the late arrivals to the existing in-flight
// entry and fans the single PLC response out to every attached client — saving the
// ECOM's per-scan PDU budget on duplicated reads.
//
// Zero post-response staleness: coalescing operates ONLY between "first request
// sent to PLC" and "response received from PLC" (microseconds to ~10 ms typical).
// Each upstream client still sees its own MBAP transaction ID echoed correctly;
// the proxy is transparent.
//
// FC06 / FC16 writes are NEVER coalesced (non-idempotent). FC03 vs FC04 are
// separate Modbus tables and never share a coalescing key. Different unit IDs
// (multi-drop / gateway-backed setups) never coalesce.
//
// Enabled — master switch. Hot-reloadable; flipping to false leaves running
// coalesced entries to drain naturally.
// MaxParties — per-entry cap on attached parties. Past the cap, the next
// identical request opens a fresh backend round-trip (load-shedding
// safety valve for very fan-out-heavy fleets).
"ReadCoalescing": {
"Enabled": true,
"MaxParties": 32
}
},
// ── Response cache (Phase 11) — opt-in bounded-staleness cache ──────────────────
//
// ⚠ DESIGN-CONTRACT PIVOT: with caching enabled the proxy is no longer purely
// transparent. Upstream FC03/FC04 reads for cache-enabled tags may return values
// up to CacheTtlMs MILLISECONDS OLD. Operators opt tags in by setting a non-zero
// CacheTtlMs on a BcdTagOptions entry (or DefaultCacheTtlMs on a PlcOptions entry).
//
// The cache is OFF BY DEFAULT for every tag. A deployment with NO TTL config (this
// section entirely absent and no BcdTags.*.CacheTtlMs / Plcs[i].DefaultCacheTtlMs)
// behaves IDENTICALLY to a pre-Phase-11 deployment — no behaviour change.
//
// AllowLongTtl — gate for any CacheTtlMs > 60_000. Reload validation
// rejects configs that exceed 60 s without this opt-in,
// to prevent accidentally-stale-for-an-hour deployments.
// MaxEntriesPerPlc — LRU cap per-PLC. Past this cap, the next insert evicts
// the least-recently-used entry. Defaults to 1000.
// EvictionIntervalMs — background eviction tick. Scans each PLC's cache and
// removes entries past their TTL. Defaults to 5000.
//
// Properties (full text in docs/Architecture/ResponseCache.md):
// * Cache hits SHORT-CIRCUIT coalescing entirely (cache → coalesce → backend).
// * Successful FC06/FC16 write responses invalidate every cached FC03/FC04 entry
// whose address range OVERLAPS the write — not just exact-key match.
// * Multi-tag read range: effective TTL = min(TTLs). Any tag with TTL=0 in the
// range disables caching for the whole read.
// * Cache stores POST-rewriter bytes; hits never re-invoke the BCD rewriter.
// * Tag-list hot-reload flushes the affected PLC's whole cache.
// * No persistence — process restart wipes the cache.
"Cache": {
"AllowLongTtl": false,
"MaxEntriesPerPlc": 1000,
"EvictionIntervalMs": 5000
}
},
// ── Serilog ─────────────────────────────────────────────────────────────────────────────
// Structured log output. Default: Information level, console + rolling-file.
// The console sink is captured by systemd-journald (view with `journalctl -u mbproxy`).
// In addition, when mbproxy runs as a systemd service the SyslogBridge writes Error+
// events to the local syslog with proper RFC5424 severity (wired in code, not here).
"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, under /var/log/mbproxy
// (created by install.sh and owned by the mbproxy service account).
// Survives uninstall — uninstall.sh archives logs to /var/log/mbproxy.archived-<ts>.
"path": "/var/log/mbproxy/mbproxy-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
]
}
}
+45
View File
@@ -0,0 +1,45 @@
# systemd unit for mbproxy — the Modbus TCP BCD proxy.
#
# Installed to /etc/systemd/system/mbproxy.service by install.sh.
# The Linux counterpart of the Windows Service registered by install.ps1.
#
# Type=exec (not Type=notify): mbproxy is a leaf service that nothing orders
# against, so systemd's readiness signal is unnecessary. Type=exec marks the
# unit active once the binary is exec'd; graceful stop still works because the
# .NET generic host handles SIGTERM directly (drains in-flight requests within
# Connection.GracefulShutdownTimeoutMs).
[Unit]
Description=mbproxy — Modbus TCP BCD proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/opt/mbproxy/Mbproxy
WorkingDirectory=/etc/mbproxy
User=mbproxy
Group=mbproxy
# Restart on crash, but not on a clean SIGTERM stop.
Restart=on-failure
RestartSec=5
# Keep above Connection.GracefulShutdownTimeoutMs (default 10 s) so the drain
# completes before systemd escalates to SIGKILL.
TimeoutStopSec=30
# Self-contained single-file publish: pin native-library extraction to a stable,
# writable directory (install.sh creates it and grants the mbproxy account access).
Environment=DOTNET_BUNDLE_EXTRACT_BASE_DIR=/var/cache/mbproxy
# Hardening. The service only needs to write its log and bundle-cache directories.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/mbproxy /var/cache/mbproxy
# If any configured ListenPort is below 1024, also add:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
+34 -21
View File
@@ -1,19 +1,27 @@
<#
.SYNOPSIS
Publishes Mbproxy.exe in two flavours: self-contained and framework-dependent.
Publishes the Mbproxy binary in two flavours: self-contained and framework-dependent.
.DESCRIPTION
Produces two single-file win-x64 builds under <repo>\publish-out\:
Produces two single-file builds for the requested runtime under <repo>\publish-out\:
self-contained\Mbproxy.exe ~100 MB — bundles the .NET 10 runtime;
no .NET install needed on target.
framework-dependent\Mbproxy.exe ~1.6 MB — requires .NET 10 + ASP.NET Core
runtime preinstalled on target.
self-contained\ ~100 MB — bundles the .NET 10 + ASP.NET Core runtime;
no .NET install needed on the target.
framework-dependent\ ~1.6 MB — requires the .NET 10 + ASP.NET Core runtime
preinstalled on the target.
Both builds use the Release configuration and inherit the publish settings
declared in src\Mbproxy\Mbproxy.csproj (PublishSingleFile=true,
IncludeNativeLibrariesForSelfExtract=true). The framework-dependent build
overrides SelfContained=false on the command line.
The runtime is selected with -Rid (default win-x64). The binary is Mbproxy.exe on
Windows RIDs and Mbproxy on Linux/macOS RIDs.
Both builds use the Release configuration and inherit the publish settings declared
in src\Mbproxy\Mbproxy.csproj (PublishSingleFile=true, SelfContained=true,
IncludeNativeLibrariesForSelfExtract=true; those settings are gated on an explicit
RID, which is supplied here). The framework-dependent build overrides
SelfContained=false on the command line.
.PARAMETER Rid
.NET runtime identifier to publish for. Examples: win-x64, linux-x64.
Default: win-x64
.PARAMETER OutputDir
Root output directory. Two subfolders are created beneath it.
@@ -24,10 +32,12 @@
.EXAMPLE
.\publish.ps1
.\publish.ps1 -Clean
.\publish.ps1 -Rid linux-x64
.\publish.ps1 -Rid win-x64 -Clean
#>
[CmdletBinding()]
param(
[string]$Rid = 'win-x64',
[string]$OutputDir = (Join-Path (Split-Path -Parent $PSScriptRoot) 'publish-out'),
[switch]$Clean
)
@@ -46,15 +56,18 @@ if ($Clean -and (Test-Path $OutputDir)) {
Remove-Item -Recurse -Force $OutputDir
}
# Binary name: Windows RIDs produce an .exe, every other RID produces an extensionless ELF/Mach-O.
$exeName = if ($Rid -like 'win-*') { 'Mbproxy.exe' } else { 'Mbproxy' }
$selfContainedOut = Join-Path $OutputDir 'self-contained'
$frameworkDependentOut = Join-Path $OutputDir 'framework-dependent'
Write-Host "`n=== Publishing self-contained (~100 MB) ===" -ForegroundColor Cyan
& dotnet publish $csproj -c Release -r win-x64 -o $selfContainedOut --nologo
Write-Host "`n=== Publishing self-contained ($Rid, ~100 MB) ===" -ForegroundColor Cyan
& dotnet publish $csproj -c Release -r $Rid -o $selfContainedOut --nologo
if ($LASTEXITCODE -ne 0) { throw "self-contained publish failed (exit $LASTEXITCODE)" }
Write-Host "`n=== Publishing framework-dependent (~1.6 MB) ===" -ForegroundColor Cyan
& dotnet publish $csproj -c Release -r win-x64 -p:SelfContained=false -p:PublishSingleFile=true -o $frameworkDependentOut --nologo
Write-Host "`n=== Publishing framework-dependent ($Rid, ~1.6 MB) ===" -ForegroundColor Cyan
& 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)" }
function Format-Size {
@@ -63,14 +76,14 @@ function Format-Size {
else { '{0:N1} KB' -f ($Bytes / 1KB) }
}
Write-Host "`n=== Result ===" -ForegroundColor Green
Write-Host "`n=== Result ($Rid) ===" -ForegroundColor Green
foreach ($flavour in 'self-contained','framework-dependent') {
$exe = Join-Path $OutputDir "$flavour\Mbproxy.exe"
if (Test-Path $exe) {
$size = (Get-Item $exe).Length
Write-Host (" {0,-22} {1,10} {2}" -f $flavour, (Format-Size $size), $exe)
$bin = Join-Path $OutputDir "$flavour\$exeName"
if (Test-Path $bin) {
$size = (Get-Item $bin).Length
Write-Host (" {0,-22} {1,10} {2}" -f $flavour, (Format-Size $size), $bin)
} else {
Write-Warning "Missing: $exe"
Write-Warning "Missing: $bin"
}
}
Write-Host ""
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# publish.sh — Linux/macOS counterpart of publish.ps1.
#
# Publishes the Mbproxy binary in two flavours for the requested runtime under
# <repo>/publish-out/:
#
# self-contained/ ~100 MB — bundles the .NET 10 + ASP.NET Core runtime;
# no .NET install needed on the target.
# framework-dependent/ ~1.6 MB — requires the .NET 10 + ASP.NET Core runtime
# preinstalled on the target.
#
# 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.
#
# Usage:
# ./publish.sh [-r RID] [-o OUTPUT_DIR] [--clean]
#
# -r RID .NET runtime identifier (default: linux-x64)
# -o OUTPUT_DIR root output directory (default: <repo>/publish-out)
# --clean delete OUTPUT_DIR before publishing
#
# Examples:
# ./publish.sh
# ./publish.sh -r linux-x64 --clean
#
set -euo pipefail
rid="linux-x64"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(dirname "$script_dir")"
output_dir="$repo_root/publish-out"
clean=0
while [[ $# -gt 0 ]]; do
case "$1" in
-r) rid="$2"; shift 2 ;;
-o) output_dir="$2"; shift 2 ;;
--clean) clean=1; shift ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
csproj="$repo_root/src/Mbproxy/Mbproxy.csproj"
if [[ ! -f "$csproj" ]]; then
echo "Cannot find $csproj" >&2
exit 1
fi
if [[ "$clean" -eq 1 && -d "$output_dir" ]]; then
echo "Cleaning $output_dir"
rm -rf "$output_dir"
fi
# Binary name: Windows RIDs produce an .exe, every other RID an extensionless binary.
if [[ "$rid" == win-* ]]; then bin_name="Mbproxy.exe"; else bin_name="Mbproxy"; fi
self_contained_out="$output_dir/self-contained"
framework_dependent_out="$output_dir/framework-dependent"
echo
echo "=== Publishing self-contained ($rid, ~100 MB) ==="
dotnet publish "$csproj" -c Release -r "$rid" -o "$self_contained_out" --nologo
echo
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
echo
echo "=== Result ($rid) ==="
for flavour in self-contained framework-dependent; do
bin="$output_dir/$flavour/$bin_name"
if [[ -f "$bin" ]]; then
size="$(du -h "$bin" | cut -f1)"
printf ' %-22s %8s %s\n' "$flavour" "$size" "$bin"
else
echo " WARNING: missing $bin" >&2
fi
done
echo
+4 -1
View File
@@ -122,7 +122,10 @@ if (Test-Path $InstallPath) {
if ([System.Diagnostics.EventLog]::SourceExists('mbproxy')) {
Write-Host "Removing Windows Event Log source 'mbproxy'..."
try {
Remove-EventLog -Source 'mbproxy'
# .NET API, not Remove-EventLog: the *-EventLog cmdlets exist only in
# Windows PowerShell 5.1, not PowerShell 7+. Symmetric with the
# SourceExists check above.
[System.Diagnostics.EventLog]::DeleteEventSource('mbproxy')
} catch {
Write-Warning "Could not remove Event Log source: $_"
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# uninstall.sh — remove the mbproxy service from a Linux / systemd host.
#
# The Linux counterpart of uninstall.ps1. Stops and disables the service,
# removes the systemd unit and installed files, and (unless --keep-config)
# removes the config directory. Log files are always preserved: they are moved
# to a timestamped archive so post-uninstall diagnostics remain accessible.
#
# Usage:
# sudo ./uninstall.sh [--keep-config] [--keep-user]
#
# --keep-config leave /etc/mbproxy/appsettings.json in place.
# --keep-user leave the mbproxy service account in place.
#
set -euo pipefail
SERVICE_NAME="mbproxy"
SERVICE_USER="mbproxy"
INSTALL_DIR="/opt/mbproxy"
CONFIG_DIR="/etc/mbproxy"
LOG_DIR="/var/log/mbproxy"
CACHE_DIR="/var/cache/mbproxy"
UNIT_DEST="/etc/systemd/system/${SERVICE_NAME}.service"
keep_config=0
keep_user=0
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-config) keep_config=1; shift ;;
--keep-user) keep_user=1; shift ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
if [[ "$(id -u)" -ne 0 ]]; then
echo "uninstall.sh must run as root (use sudo)." >&2
exit 1
fi
echo "Uninstalling ${SERVICE_NAME} service..."
# ── 1. Stop + disable the service ────────────────────────────────────────────
if systemctl list-unit-files "${SERVICE_NAME}.service" >/dev/null 2>&1 \
&& [[ -n "$(systemctl list-unit-files "${SERVICE_NAME}.service" --no-legend 2>/dev/null)" ]]; then
echo "Stopping and disabling '${SERVICE_NAME}'..."
systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
# ── 2. Remove the systemd unit ───────────────────────────────────────────────
if [[ -f "$UNIT_DEST" ]]; then
echo "Removing systemd unit '${UNIT_DEST}'..."
rm -f "$UNIT_DEST"
fi
systemctl daemon-reload
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
# ── 3. Archive logs (always preserved, never deleted) ────────────────────────
if [[ -d "$LOG_DIR" ]]; then
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
archive_dir="${LOG_DIR}.archived-${timestamp}"
echo "Archiving logs to '${archive_dir}'..."
mv "$LOG_DIR" "$archive_dir"
fi
# ── 4. Remove installed files ────────────────────────────────────────────────
rm -rf "$INSTALL_DIR" "$CACHE_DIR"
if [[ "$keep_config" -eq 1 ]]; then
echo "Keeping config at '${CONFIG_DIR}/appsettings.json' (--keep-config)."
else
rm -rf "$CONFIG_DIR"
fi
# ── 5. Remove the service account ────────────────────────────────────────────
if [[ "$keep_user" -eq 0 ]] && id -u "$SERVICE_USER" >/dev/null 2>&1; then
echo "Removing service account '${SERVICE_USER}'..."
userdel "$SERVICE_USER" 2>/dev/null || true
fi
echo ""
echo "Uninstall complete."
if compgen -G "${LOG_DIR}.archived-*" >/dev/null; then
echo "Archived logs: ${LOG_DIR}.archived-*"
fi