diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs new file mode 100644 index 00000000..ce8a71cc --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging.Abstractions; +using MQTTnet; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// CONNECT-handshake probe for the -shaped driver config. +/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response +/// (MqttClientConnectResultCode.Success) is the "device is answering" proof (green + +/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors +/// ModbusDriverProbe's shape. +/// +/// +/// +/// Reuses for the connect (TLS, +/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second +/// connect path — a duplicate would be a security divergence. A distinct client-id suffix +/// (see ) keeps a probe from colliding with the driver's +/// own session: an MQTT broker disconnects an existing client when a new one CONNECTs +/// with the same client id, so a probe that reused the driver's id would knock the +/// running driver offline every time an operator clicked "Test connect". +/// +/// +/// A broker-rejected CONNACK is not a thrown exception. Live-probed against +/// MQTTnet 5.2.0.1603: IMqttClient.ConnectAsync returns a +/// MqttClientConnectResult whose ResultCode carries the broker's CONNACK +/// reason (e.g. NotAuthorized, BadUserNameOrPassword) — it does +/// not throw for a rejected CONNACK. Only transport-level failures throw, and +/// their shape varies by where the failure occurs: a closed/refused port throws +/// MqttCommunicationException wrapping a ; an +/// untrusted broker certificate throws MqttCommunicationException wrapping an +/// ; a broker that accepts the TCP handshake and +/// then never answers CONNACK throws either MqttConnectingFailedException +/// (wrapping "Connection closed") or a bare +/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own +/// internal Timeout or the deadline token wins the race. Consequently this probe — +/// like — never switches on exception type to +/// detect a timeout; it checks the deadline token itself, which is authoritative +/// regardless of which exception shape MQTTnet happened to throw. +/// +/// +public sealed class MqttDriverProbe : IDriverProbe +{ + /// + /// Marks the transient probe identity in the broker's client-id/session logs — mirrors the + /// browser's own -browse-{guid8} suffix so the two transient sessions are + /// distinguishable in broker-side logs, and so a probe can never collide with (and knock + /// offline) the driver's own live session. + /// + internal const string ProbeClientIdPrefix = "-probe-"; + + /// + /// Shared JSON options for every MQTT config-parsing seam in this driver: enums + /// (, , protocolVersion) + /// round-trip by name, never ordinal — the repo's documented enum-serialization + /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's + /// MqttDriverFactoryExtensions is expected to converge on this same instance + /// rather than defining its own. + /// + internal static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and + // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the + // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). + public string DriverType => "Mqtt"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + MqttDriverOptions? options; + try + { + options = JsonSerializer.Deserialize(configJson, JsonOpts); + } + catch (Exception ex) + { + return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null); + } + + if (options is null) + { + return new DriverProbeResult(false, "Config JSON deserialized to null.", null); + } + + if (string.IsNullOrWhiteSpace(options.Host) || options.Port is <= 0 or > 65535) + { + return new DriverProbeResult(false, "Config has no host/port to probe.", null); + } + + // Honour the config's own connectTimeoutSeconds (factory parity, mirrors + // ModbusDriverProbe) — the caller's timeout is the fallback when the config omits it. + var effectiveSeconds = options.ConnectTimeoutSeconds > 0 + ? options.ConnectTimeoutSeconds + : (int)Math.Ceiling(timeout.TotalSeconds); + if (effectiveSeconds <= 0) + { + effectiveSeconds = 1; + } + + // Rebuild with the resolved timeout so BuildClientOptions' own WithTimeout(...) and this + // method's deadline agree — both must expire at the same instant for the deadline check + // below to be a reliable signal regardless of which of the two actually threw. + var probeOptions = options with { ConnectTimeoutSeconds = effectiveSeconds }; + + MqttClientOptions clientOptions; + try + { + clientOptions = MqttConnection.BuildClientOptions( + probeOptions, + ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8], + NullLogger.Instance); + } + catch (Exception ex) + { + // Never let a configured Password reach the message — BuildClientOptions itself never + // logs it, and no exception path through it can format credential values. + return new DriverProbeResult(false, $"Config could not be used to build a connection: {ex.Message}", null); + } + + var target = $"{probeOptions.Host}:{probeOptions.Port}"; + var sw = Stopwatch.StartNew(); + + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(effectiveSeconds)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token); + + using var client = new MqttClientFactory().CreateMqttClient(); + + MqttClientConnectResult result; + try + { + result = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + return new DriverProbeResult(false, Classify(target, ex, ct, deadline, effectiveSeconds), null); + } + + try + { + if (result.ResultCode != MqttClientConnectResultCode.Success) + { + return new DriverProbeResult(false, ClassifyRejectedConnack(target, result.ResultCode), null); + } + + sw.Stop(); + return new DriverProbeResult(true, "MQTT CONNECT OK", sw.Elapsed); + } + finally + { + await DisconnectBestEffortAsync(client, effectiveSeconds).ConfigureAwait(false); + } + } + + /// + /// Maps a broker-rejected CONNACK ( not + /// Success — never an exception, see the type remarks) to a targeted, credential-free + /// message. + /// + private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => code switch + { + MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword + => $"Broker at {target} rejected the credentials ({code}).", + MqttClientConnectResultCode.UnsupportedProtocolVersion + => $"Broker at {target} does not support the configured protocol version ({code}).", + MqttClientConnectResultCode.Banned + => $"Broker at {target} has banned this client ({code}).", + _ => $"Broker at {target} refused CONNECT: {code}.", + }; + + /// + /// Classifies a thrown connect failure. Precedence mirrors + /// : caller cancellation, then the deadline, then the + /// exception's own shape — the deadline check is authoritative regardless of which exception + /// type MQTTnet happened to throw (see the type remarks for why exception type alone is not + /// reliable here). + /// + private static string Classify( + string target, + Exception ex, + CancellationToken callerToken, + CancellationTokenSource deadline, + int effectiveSeconds) + { + if (callerToken.IsCancellationRequested) + { + return "Probe was cancelled."; + } + + if (deadline.IsCancellationRequested) + { + return $"Probe timed out after {effectiveSeconds}s."; + } + + // Walk to the root cause — MQTTnet wraps transport/TLS failures one or two levels deep. + var root = ex; + while (root.InnerException is not null) + { + root = root.InnerException; + } + + return root switch + { + SocketException se => $"Connect to {target} failed: {se.SocketErrorCode}", + AuthenticationException => $"TLS handshake with {target} failed: {root.Message}", + _ => $"Connect to {target} failed: {root.Message}", + }; + } + + /// + /// Best-effort teardown of a session this probe established. Failure here is not the + /// probe's business — the CONNECT outcome already decided Ok/failure — and it must never be + /// allowed to hang past the connect budget. + /// + private static async Task DisconnectBestEffortAsync(IMqttClient client, int timeoutSeconds) + { + if (!client.IsConnected) + { + return; + } + + try + { + using var disconnectDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + await client.DisconnectAsync(new MqttClientDisconnectOptions(), disconnectDeadline.Token) + .ConfigureAwait(false); + } + catch + { + // Best-effort only. + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs new file mode 100644 index 00000000..14c70eab --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Tests for . Mirrors ModbusDriverProbeTests' +/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT +/// handshake is exercised against something real rather than mocked. +/// +[Trait("Category", "Unit")] +public sealed class MqttDriverProbeTests +{ + private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3); + + /// A loopback listener that accepts the TCP connection and then never answers CONNACK — + /// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and + /// would pass whether or not the probe's deadline logic works at all, so the timeout test below + /// needs this rather than a closed port. + private sealed class BlackholeBroker : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly List _accepted = []; + + public BlackholeBroker() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); + lock (_accepted) + { + _accepted.Add(client); + } + // Accept the TCP handshake and then say nothing — never a CONNACK. + } + } + catch (Exception) + { + // Listener stopped / cancelled — expected at teardown. + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + lock (_accepted) + { + foreach (var client in _accepted) + { + client.Dispose(); + } + } + _cts.Dispose(); + } + } + + private static TcpListener StartListener() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + return l; + } + + private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port; + + [Fact] + public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt"); + + /// The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED) + /// regardless of whether the deadline logic works. Kept as the "refused" leg, not the + /// "deadline" leg — see + /// for the real timeout-path exercise. + [Fact] + public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + var r = await new MqttDriverProbe().ProbeAsync( + $$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""", + QuickTimeout, + CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + /// The real timeout-path exercise: a broker that completes the TCP handshake and then + /// never answers CONNACK must still fail at the configured deadline rather than hang. + [Fact] + public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang() + { + using var blackhole = new BlackholeBroker(); + var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}"""; + + var sw = Stopwatch.StartNew(); + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + sw.Stop(); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldContain("timed out", Case.Insensitive); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown() + { + var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task ProbeAsync_NoHost_ReturnsFailedResult() + { + var r = await new MqttDriverProbe().ProbeAsync( + """{"host":"","port":0}""", QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } + + /// Never leak a configured password into the probe's result message — it surfaces + /// directly in the AdminUI Test-connect UI. + [Fact] + public async Task ProbeAsync_NeverLeaksPasswordIntoMessage() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + const string secret = "S3cr3tSquirrel!!"; + var config = $$""" + {"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2} + """; + + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + r.Message.ShouldNotContain(secret); + } + + /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented + /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs. + [Fact] + public async Task ProbeAsync_EnumAsName_DoesNotThrow() + { + var listener = StartListener(); + var port = ListenerPort(listener); + listener.Stop(); + + var config = $$""" + {"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2} + """; + + var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None); + + r.Ok.ShouldBeFalse(); + r.Message.ShouldNotBeNullOrEmpty(); + } +}