using System.Diagnostics; using System.Net.Sockets; using System.Security.Authentication; using System.Text.Json; 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-"; /// public string DriverType => DriverTypeNames.Mqtt; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { MqttDriverOptions? options; try { // MqttJson.Options — the one shared instance across factory / probe / driver / browser // (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that // Test-connect accepts would fault the deployed driver. options = JsonSerializer.Deserialize(configJson, MqttJson.Options); } 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. /// /// /// Delegates to so the "Test connect" /// button and the running driver report an identical rejection in identical words — they now /// both classify CONNACK, and disagreeing about it was the exact confusion the Task-13 live /// gate surfaced. /// private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => MqttConnection.DescribeConnackRejection(target, 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. } } }