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
@@ -0,0 +1,40 @@
using Mbproxy.Diagnostics;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Diagnostics;
/// <summary>
/// Unit tests for <see cref="DiagnosticSinkSelector"/> — the pure platform-selection
/// logic for the Error+ diagnostic sink. Covers every OS / host combination so the
/// selection contract is pinned without needing a real Windows Service or systemd host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DiagnosticSinkSelectorTests
{
// 'expected' is the underlying int of DiagnosticSink: the enum is internal and
// cannot appear in a public (xunit-discoverable) method signature.
[Theory]
[InlineData(true, true, false, (int)DiagnosticSink.EventLog)] // Windows, hosted as a Windows Service
[InlineData(true, false, false, (int)DiagnosticSink.None)] // Windows, interactive / dev run
[InlineData(false, false, true, (int)DiagnosticSink.Syslog)] // Linux, hosted as a systemd service
[InlineData(false, false, false, (int)DiagnosticSink.None)] // Linux / macOS, interactive / dev run
public void Select_PicksExpectedSink(
bool isWindows, bool isWindowsService, bool isSystemd, int expected)
=> ((int)DiagnosticSinkSelector.Select(isWindows, isWindowsService, isSystemd)).ShouldBe(expected);
[Fact]
public void Select_Windows_TakesPrecedence_OverASpuriousSystemdFlag()
=> DiagnosticSinkSelector.Select(isWindows: true, isWindowsService: true, isSystemd: true)
.ShouldBe(DiagnosticSink.EventLog);
[Fact]
public void Select_WindowsConsoleRun_GetsNoSink_EvenIfSystemdFlagSet()
=> DiagnosticSinkSelector.Select(isWindows: true, isWindowsService: false, isSystemd: true)
.ShouldBe(DiagnosticSink.None);
[Fact]
public void Select_NonWindowsWithoutSystemd_GetsNoSink()
=> DiagnosticSinkSelector.Select(isWindows: false, isWindowsService: false, isSystemd: false)
.ShouldBe(DiagnosticSink.None);
}
@@ -0,0 +1,41 @@
using Mbproxy.Diagnostics;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Diagnostics;
/// <summary>
/// Unit tests for <see cref="EventLogMessage.TruncateToLimit"/> — the 32 KB Windows
/// Event Log truncation rule. The helper is pure and OS-agnostic, so these run on
/// every platform (the Windows-only <see cref="EventLogBridge"/> sink itself is not
/// exercised here).
/// </summary>
[Trait("Category", "Unit")]
public sealed class EventLogMessageTests
{
[Fact]
public void TruncateToLimit_ShortMessage_ReturnedUnchanged()
{
const string msg = "mbproxy backend connect failed";
EventLogMessage.TruncateToLimit(msg).ShouldBeSameAs(msg);
}
[Fact]
public void TruncateToLimit_MessageAtTheLimit_NotTruncated()
{
// MaxBytes / 2 chars = exactly MaxBytes at the 2-bytes-per-char upper bound.
var atLimit = new string('y', EventLogMessage.MaxBytes / 2);
EventLogMessage.TruncateToLimit(atLimit).ShouldBe(atLimit);
}
[Fact]
public void TruncateToLimit_OversizeMessage_TruncatedWithinLimit_AndEndsWithEllipsis()
{
var huge = new string('x', EventLogMessage.MaxBytes); // well over the limit
var result = EventLogMessage.TruncateToLimit(huge);
(result.Length * 2).ShouldBeLessThanOrEqualTo(EventLogMessage.MaxBytes);
result.ShouldEndWith("...");
result.Length.ShouldBeLessThan(huge.Length);
}
}
@@ -0,0 +1,27 @@
using Mbproxy.Diagnostics;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Diagnostics;
/// <summary>
/// Unit tests for <see cref="SyslogBridge"/>. The bridge's fail-safe contract is that
/// attaching the local-syslog sink and building the resulting logger never throw —
/// even on a host with no <c>/dev/log</c> (e.g. the Windows test leg), where the sink
/// connects lazily and degrades silently.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SyslogBridgeTests
{
[Fact]
public void AttachTo_ReturnsAConfiguration_AndNeverThrows()
=> SyslogBridge.AttachTo(new LoggerConfiguration()).ShouldNotBeNull();
[Fact]
public void AttachTo_ResultCreatesALogger_WithoutThrowing()
{
using var logger = SyslogBridge.AttachTo(new LoggerConfiguration()).CreateLogger();
logger.ShouldNotBeNull();
}
}