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
@@ -102,6 +102,39 @@ public sealed class MbproxyOptionsBindingTests
options.Resilience.ListenerRecovery.InitialBackoffMs.ShouldBe([1000, 2000, 5000, 15000, 30000]);
options.Plcs.ShouldBeEmpty();
options.BcdTags.Global.ShouldBeEmpty();
// Keepalive defaults — enabled, with the documented timer values.
options.Connection.Keepalive.Enabled.ShouldBeTrue();
options.Connection.Keepalive.TcpIdleTimeMs.ShouldBe(30000);
options.Connection.Keepalive.TcpProbeIntervalMs.ShouldBe(5000);
options.Connection.Keepalive.TcpProbeCount.ShouldBe(4);
options.Connection.Keepalive.BackendHeartbeatIdleMs.ShouldBe(30000);
options.Connection.Keepalive.BackendHeartbeatProbeAddress.ShouldBe(0);
}
// -------------------------------------------------------------------------
// Test 5 — the Connection:Keepalive block binds from configuration
// -------------------------------------------------------------------------
[Fact]
public void MbproxyOptionsBinding_BindsKeepaliveBlock()
{
var options = BindOptions(new Dictionary<string, string?>
{
["Mbproxy:Connection:Keepalive:Enabled"] = "false",
["Mbproxy:Connection:Keepalive:TcpIdleTimeMs"] = "45000",
["Mbproxy:Connection:Keepalive:TcpProbeIntervalMs"] = "7000",
["Mbproxy:Connection:Keepalive:TcpProbeCount"] = "6",
["Mbproxy:Connection:Keepalive:BackendHeartbeatIdleMs"] = "20000",
["Mbproxy:Connection:Keepalive:BackendHeartbeatProbeAddress"] = "1024",
});
var ka = options.Connection.Keepalive;
ka.Enabled.ShouldBeFalse();
ka.TcpIdleTimeMs.ShouldBe(45000);
ka.TcpProbeIntervalMs.ShouldBe(7000);
ka.TcpProbeCount.ShouldBe(6);
ka.BackendHeartbeatIdleMs.ShouldBe(20000);
ka.BackendHeartbeatProbeAddress.ShouldBe(1024);
}
// -------------------------------------------------------------------------
@@ -129,4 +162,47 @@ public sealed class MbproxyOptionsBindingTests
result.Failed.ShouldBeTrue("Width=8 should fail schema validation");
result.Failures.ShouldNotBeEmpty();
}
// -------------------------------------------------------------------------
// Test 6 — every shipped install template (Windows + Linux) loads as JSONC,
// binds to MbproxyOptions, and passes schema validation. This catches
// a malformed template at build time and keeps the two platform
// variants in lockstep.
// -------------------------------------------------------------------------
[Theory]
[InlineData("mbproxy.config.template.json")]
[InlineData("mbproxy.linux.config.template.json")]
public void MbproxyOptionsBinding_ShippedInstallTemplate_BindsAndValidates(string templateFileName)
{
var templatePath = ResolveInstallFile(templateFileName);
// The templates are JSONC; the .NET JSON config provider skips // and /* */
// comments and allows trailing commas, so AddJsonFile loads them directly.
var config = new ConfigurationBuilder()
.AddJsonFile(templatePath, optional: false)
.Build();
var options = config.GetSection("Mbproxy").Get<MbproxyOptions>() ?? new MbproxyOptions();
var result = new MbproxyOptionsValidator().Validate(null, options);
result.Succeeded.ShouldBeTrue(
$"{templateFileName} must pass schema validation — failures: " +
string.Join("; ", result.Failures ?? []));
}
/// <summary>
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
/// Works from both the Windows dev box and the Linux test box.
/// </summary>
private static string ResolveInstallFile(string fileName)
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "install", fileName);
if (File.Exists(candidate))
return candidate;
}
throw new FileNotFoundException(
$"Could not locate install/{fileName} above {AppContext.BaseDirectory}");
}
}