0308490aef
Resolves the remaining Minor items from the 2026-05-15 review so the web-UI dashboard work has no open follow-ups: a real-HubConnection end-to-end test for the SignalR feed, stable mbproxy.admin.broadcast.* log-event names, keyboard/aria accessibility on the fleet table, frontend JS hardening (URL-decode guard, NaN guards, shared util.js), reconciler<->capture-registry coverage, throwing-sink and embedded-asset tests, broadcaster polish, and a soft upper bound on AdminPushIntervalMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
584 lines
25 KiB
C#
584 lines
25 KiB
C#
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using Mbproxy.Admin;
|
|
using Mbproxy.Options;
|
|
using Mbproxy.Proxy;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Configuration.Memory;
|
|
using NModbus;
|
|
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Admin;
|
|
|
|
/// <summary>
|
|
/// End-to-end HTTP-level tests for the admin endpoint.
|
|
/// Each test starts an in-process host with a live Kestrel admin server and verifies
|
|
/// the shape and content of the responses.
|
|
///
|
|
/// Tests that require a Modbus simulator skip gracefully when Python / pymodbus
|
|
/// is not available.
|
|
/// </summary>
|
|
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
|
[Trait("Category", "E2E")]
|
|
public sealed class AdminEndpointTests
|
|
{
|
|
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
|
private static readonly HttpClient HttpClient = new();
|
|
|
|
public AdminEndpointTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
|
{
|
|
_sim = sim;
|
|
}
|
|
|
|
// ── 1. GET /status.json returns valid JSON with expected top-level shape ──
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_StatusJson_ReturnsValidShape()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json",
|
|
TestContext.Current.CancellationToken);
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
|
|
|
|
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
var doc = JsonDocument.Parse(body);
|
|
var root = doc.RootElement;
|
|
|
|
// service sub-object
|
|
root.TryGetProperty("service", out var svc).ShouldBeTrue("Missing 'service' field");
|
|
svc.TryGetProperty("uptimeSeconds", out var svcUptime).ShouldBeTrue("Missing service.uptimeSeconds");
|
|
svc.TryGetProperty("version", out var svcVersion).ShouldBeTrue("Missing service.version");
|
|
svc.TryGetProperty("configReloadCount", out var svcReload).ShouldBeTrue("Missing service.configReloadCount");
|
|
|
|
// listeners sub-object
|
|
root.TryGetProperty("listeners", out var lst).ShouldBeTrue("Missing 'listeners' field");
|
|
lst.TryGetProperty("bound", out var lstBound).ShouldBeTrue("Missing listeners.bound");
|
|
lst.TryGetProperty("configured", out var lstConfigured).ShouldBeTrue("Missing listeners.configured");
|
|
|
|
// plcs array
|
|
root.TryGetProperty("plcs", out var plcs).ShouldBeTrue("Missing 'plcs' field");
|
|
plcs.ValueKind.ShouldBe(JsonValueKind.Array);
|
|
|
|
// per-plc shape (only if PLCs configured)
|
|
if (plcs.GetArrayLength() > 0)
|
|
{
|
|
var plc0 = plcs[0];
|
|
plc0.TryGetProperty("name", out var plcName).ShouldBeTrue("Missing plc.name");
|
|
plc0.TryGetProperty("listener", out var listener).ShouldBeTrue("Missing plc.listener");
|
|
listener.TryGetProperty("state", out var listenerState).ShouldBeTrue("Missing plc.listener.state");
|
|
plc0.TryGetProperty("clients", out var clients).ShouldBeTrue("Missing plc.clients");
|
|
clients.TryGetProperty("connected", out var clientsConn).ShouldBeTrue("Missing plc.clients.connected");
|
|
clients.TryGetProperty("remoteEndpoints", out var clientsRemote).ShouldBeTrue("Missing plc.clients.remoteEndpoints");
|
|
plc0.TryGetProperty("pdus", out var pdus).ShouldBeTrue("Missing plc.pdus");
|
|
pdus.TryGetProperty("forwarded", out var pdusForwarded).ShouldBeTrue("Missing plc.pdus.forwarded");
|
|
pdus.TryGetProperty("byFc", out var pdusByFc).ShouldBeTrue("Missing plc.pdus.byFc");
|
|
plc0.TryGetProperty("backend", out var backend).ShouldBeTrue("Missing plc.backend");
|
|
backend.TryGetProperty("lastRoundTripMs", out var backendRtt).ShouldBeTrue("Missing plc.backend.lastRoundTripMs");
|
|
plc0.TryGetProperty("bytes", out var bytes).ShouldBeTrue("Missing plc.bytes");
|
|
bytes.TryGetProperty("upstreamIn", out var bytesIn).ShouldBeTrue("Missing plc.bytes.upstreamIn");
|
|
}
|
|
}
|
|
|
|
// ── 2. PDU count increases after FC03 read ────────────────────────────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_StatusJson_AfterReadFC03_ShowsPduCountIncreased()
|
|
{
|
|
if (_sim.SkipReason is not null)
|
|
Assert.Skip(_sim.SkipReason);
|
|
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
|
|
proxyPort: proxyPort, bcd16Addresses: [1072]);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
await WaitForListenerAsync(proxyPort);
|
|
|
|
// Read baseline PDU count.
|
|
long before = await GetPduForwardedAsync(adminPort);
|
|
|
|
// Perform one FC03 read through the proxy.
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
|
var master = new ModbusFactory().CreateMaster(client);
|
|
master.ReadHoldingRegisters(1, 1072, 1);
|
|
|
|
// Give counters time to propagate.
|
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
|
|
|
long after = await GetPduForwardedAsync(adminPort);
|
|
after.ShouldBeGreaterThan(before, "PDU count should increase after an FC03 read");
|
|
}
|
|
|
|
// ── 3. Partial BCD warning appears after partial overlap read ────────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_StatusJson_AfterPartialBcdWrite_ShowsPartialBcdWarning()
|
|
{
|
|
if (_sim.SkipReason is not null)
|
|
Assert.Skip(_sim.SkipReason);
|
|
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
// Configure a 32-bit BCD tag at 1072/1073.
|
|
var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
|
|
proxyPort: proxyPort, bcd32Addresses: [1072]);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
await WaitForListenerAsync(proxyPort);
|
|
|
|
// Read baseline partial BCD warning count.
|
|
long before = await GetPartialBcdWarningsAsync(adminPort);
|
|
|
|
// Read only the HIGH register (1073) of the 32-bit pair → partial overlap.
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
|
var master = new ModbusFactory().CreateMaster(client);
|
|
master.ReadHoldingRegisters(1, 1073, 1); // partial overlap
|
|
|
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
|
|
|
long after = await GetPartialBcdWarningsAsync(adminPort);
|
|
after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
|
|
}
|
|
|
|
// ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_Root_ReturnsDashboardShell()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/",
|
|
TestContext.Current.CancellationToken);
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
|
|
|
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
body.ShouldContain("<!doctype html>");
|
|
body.ShouldContain("/assets/dashboard.js");
|
|
}
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_PlcDetailRoute_ReturnsDetailShell()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
|
|
TestContext.Current.CancellationToken);
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
|
|
|
string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
body.ShouldContain("/assets/detail.js");
|
|
}
|
|
|
|
[Theory(Timeout = 5_000)]
|
|
[InlineData("bootstrap.min.css", "text/css")]
|
|
[InlineData("signalr.min.js", "text/javascript")]
|
|
[InlineData("dashboard.js", "text/javascript")]
|
|
[InlineData("theme.css", "text/css")]
|
|
[InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
|
|
public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
|
|
TestContext.Current.CancellationToken);
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
|
|
response.Headers.CacheControl?.ToString().ShouldContain("immutable");
|
|
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
|
|
|
// The served bytes must be the actual embedded asset — not some other resource
|
|
// of the same length. Compare against the manifest resource directly.
|
|
byte[] expected = ReadEmbeddedAsset(file);
|
|
bytes.ShouldBe(expected, $"GET /assets/{file} must return the embedded asset verbatim");
|
|
}
|
|
|
|
/// <summary>Reads a <c>wwwroot</c> asset straight from the assembly's manifest resources.</summary>
|
|
private static byte[] ReadEmbeddedAsset(string fileName)
|
|
{
|
|
using var stream = typeof(Mbproxy.Admin.StatusHub).Assembly
|
|
.GetManifestResourceStream("Mbproxy.Admin.wwwroot." + fileName)
|
|
?? throw new InvalidOperationException($"Embedded asset not found: {fileName}");
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Get_UnknownAsset_Returns404()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
|
|
TestContext.Current.CancellationToken);
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
|
}
|
|
|
|
// ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task AdminPort_BindFailure_ServiceStaysUp_AndLogsBindFailed()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
// Occupy the admin port on ANY with exclusive use so the proxy Kestrel cannot bind it.
|
|
var occupier = new TcpListener(IPAddress.Any, adminPort);
|
|
occupier.Server.SetSocketOption(
|
|
SocketOptionLevel.Socket,
|
|
SocketOptionName.ExclusiveAddressUse,
|
|
true);
|
|
occupier.Start();
|
|
|
|
try
|
|
{
|
|
var logSink = new CapturingSink();
|
|
var serilog = new LoggerConfiguration()
|
|
.MinimumLevel.Error()
|
|
.WriteTo.Sink(logSink)
|
|
.CreateLogger();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: [], serilogOverride: serilog);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
|
|
// StartAsync should NOT throw even though the admin port is taken.
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
// Give the service time to attempt the bind.
|
|
await Task.Delay(500, TestContext.Current.CancellationToken);
|
|
|
|
// The Modbus proxy listener should still be up.
|
|
bool proxyUp = CanConnect(proxyPort);
|
|
proxyUp.ShouldBeTrue("Proxy listener should still be reachable despite admin bind failure");
|
|
|
|
// The bind-failed event should have been logged.
|
|
bool logged = logSink.Events.Any(e =>
|
|
e.MessageTemplate.Text.Contains("mbproxy.admin.bind.failed") ||
|
|
e.MessageTemplate.Text.Contains("Admin endpoint bind failed"));
|
|
logged.ShouldBeTrue("mbproxy.admin.bind.failed should be logged when the admin port is in use");
|
|
}
|
|
finally
|
|
{
|
|
occupier.Stop();
|
|
}
|
|
}
|
|
|
|
// ── 6. AdminPort hot-reload → server re-binds to new port ────────────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task AdminPort_HotReload_RebindsToNewPort()
|
|
{
|
|
int adminPort1 = PickFreePort();
|
|
int adminPort2 = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
// Write initial config to a temp file.
|
|
string configPath = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(),
|
|
$"mbproxy_admin_hotreload_{Guid.NewGuid():N}.json");
|
|
|
|
try
|
|
{
|
|
WriteConfig(configPath, adminPort: adminPort1, proxyPort: proxyPort);
|
|
|
|
var logger = new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
|
|
var builder = Host.CreateApplicationBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddJsonFile(configPath, optional: false, reloadOnChange: true);
|
|
builder.Services.AddSerilog(logger, dispose: false);
|
|
builder.AddMbproxyOptions();
|
|
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
|
builder.Services.AddSingleton<ProxyWorker>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
|
builder.AddMbproxyAdmin();
|
|
|
|
using var host = builder.Build();
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.StartAsync(startCts.Token);
|
|
|
|
await WaitForAdminAsync(adminPort1);
|
|
|
|
// Mutate the config file to change AdminPort.
|
|
WriteConfig(configPath, adminPort: adminPort2, proxyPort: proxyPort);
|
|
|
|
// Wait for admin endpoint to re-bind on new port.
|
|
await WaitForAdminAsync(adminPort2);
|
|
|
|
// Old port should no longer serve requests.
|
|
bool oldPortStillUp;
|
|
try
|
|
{
|
|
var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort1}/status.json",
|
|
new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token);
|
|
oldPortStillUp = r.IsSuccessStatusCode;
|
|
}
|
|
catch
|
|
{
|
|
oldPortStillUp = false;
|
|
}
|
|
|
|
oldPortStillUp.ShouldBeFalse("Old admin port should no longer be active after hot-reload");
|
|
|
|
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await host.StopAsync(stopCts.Token);
|
|
}
|
|
finally
|
|
{
|
|
try { System.IO.File.Delete(configPath); } catch { }
|
|
}
|
|
}
|
|
|
|
private static void WriteConfig(string path, int adminPort, int proxyPort)
|
|
{
|
|
var doc = new
|
|
{
|
|
Mbproxy = new
|
|
{
|
|
AdminPort = adminPort,
|
|
BcdTags = new { Global = Array.Empty<object>() },
|
|
Plcs = new[] { new { Name = "PLC-A", ListenPort = proxyPort, Host = "127.0.0.1", Port = 502 } },
|
|
Connection = new { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 500 },
|
|
},
|
|
};
|
|
|
|
string tmp = path + ".tmp";
|
|
System.IO.File.WriteAllText(tmp,
|
|
System.Text.Json.JsonSerializer.Serialize(doc,
|
|
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
|
System.IO.File.Move(tmp, path, overwrite: true);
|
|
}
|
|
|
|
// ── non-GET methods rejected ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
|
|
/// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
|
|
/// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
|
|
/// here.) Guards against an accidental MapPost/Map* being added later.
|
|
/// </summary>
|
|
[Theory(Timeout = 5_000)]
|
|
[InlineData("POST")]
|
|
[InlineData("PUT")]
|
|
[InlineData("DELETE")]
|
|
[InlineData("PATCH")]
|
|
public async Task NonGetMethod_AgainstAdminRoutes_Returns405(string method)
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
|
|
proxyPort: proxyPort, bcd16Addresses: []);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
await host.StartAsync(TestContext.Current.CancellationToken);
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
foreach (string path in new[] { "/", "/status.json" })
|
|
{
|
|
using var req = new HttpRequestMessage(new HttpMethod(method),
|
|
$"http://127.0.0.1:{adminPort}{path}");
|
|
using var resp = await HttpClient.SendAsync(req, TestContext.Current.CancellationToken);
|
|
resp.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed,
|
|
$"{method} {path} must be rejected (admin endpoint is read-only)");
|
|
}
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static IHost BuildHost(
|
|
int adminPort,
|
|
string simHost,
|
|
int simPort,
|
|
int proxyPort,
|
|
ushort[]? bcd16Addresses = null,
|
|
ushort[]? bcd32Addresses = null,
|
|
Serilog.ILogger? serilogOverride = null)
|
|
{
|
|
var config = new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:AdminPort"] = adminPort.ToString(),
|
|
["Mbproxy:Plcs:0:Name"] = "TestPLC",
|
|
["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
|
["Mbproxy:Plcs:0:Host"] = simHost,
|
|
["Mbproxy:Plcs:0:Port"] = simPort.ToString(),
|
|
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
|
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
|
};
|
|
|
|
int tagIndex = 0;
|
|
foreach (ushort addr in bcd16Addresses ?? [])
|
|
{
|
|
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
|
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "16";
|
|
tagIndex++;
|
|
}
|
|
foreach (ushort addr in bcd32Addresses ?? [])
|
|
{
|
|
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
|
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "32";
|
|
tagIndex++;
|
|
}
|
|
|
|
var logger = serilogOverride
|
|
?? new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
|
|
|
|
var builder = Host.CreateApplicationBuilder();
|
|
builder.Configuration.AddInMemoryCollection(config);
|
|
builder.Services.AddSerilog(logger, dispose: false);
|
|
builder.AddMbproxyOptions();
|
|
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
|
// Register as singleton so StatusSnapshotBuilder can inject ProxyWorker directly.
|
|
builder.Services.AddSingleton<ProxyWorker>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
|
builder.AddMbproxyAdmin();
|
|
|
|
return builder.Build();
|
|
}
|
|
|
|
private static async Task WaitForAdminAsync(int adminPort)
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json", cts.Token);
|
|
if (r.StatusCode == System.Net.HttpStatusCode.OK) return;
|
|
}
|
|
catch { }
|
|
await Task.Delay(100, cts.Token).ConfigureAwait(false);
|
|
}
|
|
throw new TimeoutException($"Admin endpoint on port {adminPort} did not start in time.");
|
|
}
|
|
|
|
private static async Task WaitForListenerAsync(int proxyPort)
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
if (CanConnect(proxyPort)) return;
|
|
await Task.Delay(50, cts.Token).ConfigureAwait(false);
|
|
}
|
|
throw new TimeoutException($"Proxy listener on port {proxyPort} did not start in time.");
|
|
}
|
|
|
|
private static async Task<long> GetPduForwardedAsync(int adminPort)
|
|
{
|
|
string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
|
|
var doc = JsonDocument.Parse(body);
|
|
var plcs = doc.RootElement.GetProperty("plcs");
|
|
if (plcs.GetArrayLength() == 0) return 0;
|
|
return plcs[0].GetProperty("pdus").GetProperty("forwarded").GetInt64();
|
|
}
|
|
|
|
private static async Task<long> GetPartialBcdWarningsAsync(int adminPort)
|
|
{
|
|
string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
|
|
var doc = JsonDocument.Parse(body);
|
|
var plcs = doc.RootElement.GetProperty("plcs");
|
|
if (plcs.GetArrayLength() == 0) return 0;
|
|
return plcs[0].GetProperty("pdus").GetProperty("partialBcdWarnings").GetInt64();
|
|
}
|
|
|
|
private static int PickFreePort()
|
|
{
|
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
|
l.Start();
|
|
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return port;
|
|
}
|
|
|
|
private static bool CanConnect(int port)
|
|
{
|
|
try { using var c = new TcpClient(); c.Connect("127.0.0.1", port); return true; }
|
|
catch { return false; }
|
|
}
|
|
|
|
private sealed class AsyncHostDispose : IAsyncDisposable
|
|
{
|
|
private readonly IHost _host;
|
|
public AsyncHostDispose(IHost host) => _host = host;
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
try { await _host.StopAsync(cts.Token); } catch { }
|
|
_host.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingSink : Serilog.Core.ILogEventSink
|
|
{
|
|
private readonly System.Collections.Concurrent.ConcurrentQueue<Serilog.Events.LogEvent> _q = new();
|
|
public System.Collections.Generic.IEnumerable<Serilog.Events.LogEvent> Events => _q;
|
|
public void Emit(Serilog.Events.LogEvent e) => _q.Enqueue(e);
|
|
}
|
|
}
|