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:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 (0–9999).
|
||||
// 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 MaxAttempts−1 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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
@@ -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: $_"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user