7f53bd96ec
Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser layering exception. No runtime driver behaviour changes. #513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key unconditionally. Sharper than the issue states: PayloadFormat is the one Plain field with a NON-NULLABLE typed backing, so the write did not preserve a retyped tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never had one. Now Plain-only; preserve-unknown-keys is pinned unchanged. #509 slashed Sparkplug metric names. The mapper already carried metricName verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment, which refused the batch whole — including Node Control/Rebirth, mandatory on every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address untouched. Coercion is not injective, so DescribeNameCollisions refuses a colliding set BY NAME: silently merging two metrics into one tag would be worse than the refusal it replaces. Applies to every driver, not just MQTT — the argument is about what a Name is, not about who produced it. The sanitiser is pinned against RawPaths.ValidateSegment rather than a copied charset. #510 stale rebirth affordance. _canRebirth was probed once at browse-open and sessions are reaped for idleness, so a modal left open offered an action whose token no longer resolved. Re-probed on arm AND on confirm (the reap can land between the two, and confirm is the one path that publishes to a live broker); the affordance retires with an explanation instead of vanishing or erroring. #514 rebirth on a quiet plant. The tree is birth-driven and births are never retained, so a plant that birthed before the window opened showed an empty tree — and the scope came only from a tree selection, so the one action that would fill the tree could not be armed. The session already accepted a bare {group}/{edgeNode} ("the prime rebirth target"); this adds the affordance. Edge-node scope only, not a group broadcast. The rule is extracted to SparkplugRebirthScope so it is testable — this repo has no bUnit — and is advisory: the session re-resolves and refuses before publishing. #512 .Browser -> .Driver layering. TWO extractions were needed, not the one the issue anticipated: it costed this as "the client-options builder", but the browse session decodes births itself, so moving only that would have left the reference exactly where it was. - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had already prescribed, against the issue's suggestion. - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec, RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes. Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards the graph both ways. Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the SAME violation, undocumented — the old comment blamed this browser for Core and was wrong. Filed separately rather than fixed in passing. Full suite green apart from the 7 known pre-existing failures (4 AbLegacy integration, compose profiles not up; 3 Host.IntegrationTests). Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
227 lines
10 KiB
C#
227 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// CONNECT-handshake probe for the <see cref="MqttDriverOptions"/>-shaped driver config.
|
|
/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response
|
|
/// (<c>MqttClientConnectResultCode.Success</c>) is the "device is answering" proof (green +
|
|
/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors
|
|
/// <c>ModbusDriverProbe</c>'s shape.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Reuses <see cref="MqttClientOptionsFactory.BuildClientOptions"/></b> 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 <see cref="ProbeClientIdPrefix"/>) 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".
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A broker-rejected CONNACK is not a thrown exception.</b> Live-probed against
|
|
/// MQTTnet 5.2.0.1603: <c>IMqttClient.ConnectAsync</c> returns a
|
|
/// <c>MqttClientConnectResult</c> whose <c>ResultCode</c> carries the broker's CONNACK
|
|
/// reason (e.g. <c>NotAuthorized</c>, <c>BadUserNameOrPassword</c>) — it does
|
|
/// <b>not</b> throw for a rejected CONNACK. Only transport-level failures throw, and
|
|
/// their shape varies by where the failure occurs: a closed/refused port throws
|
|
/// <c>MqttCommunicationException</c> wrapping a <see cref="SocketException"/>; an
|
|
/// untrusted broker certificate throws <c>MqttCommunicationException</c> wrapping an
|
|
/// <see cref="AuthenticationException"/>; a broker that accepts the TCP handshake and
|
|
/// then never answers CONNACK throws either <c>MqttConnectingFailedException</c>
|
|
/// (wrapping "Connection closed") or a bare <see cref="OperationCanceledException"/>
|
|
/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own
|
|
/// internal <c>Timeout</c> or the deadline token wins the race. Consequently this probe —
|
|
/// like <see cref="MqttConnection.Classify"/> — 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MqttDriverProbe : IDriverProbe
|
|
{
|
|
/// <summary>
|
|
/// Marks the transient probe identity in the broker's client-id/session logs — mirrors the
|
|
/// browser's own <c>-browse-{guid8}</c> 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.
|
|
/// </summary>
|
|
internal const string ProbeClientIdPrefix = "-probe-";
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => DriverTypeNames.Mqtt;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> 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<MqttDriverOptions>(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 = MqttClientOptionsFactory.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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a broker-rejected CONNACK (<see cref="MqttClientConnectResult.ResultCode"/> not
|
|
/// <c>Success</c> — never an exception, see the type remarks) to a targeted, credential-free
|
|
/// message.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Delegates to <see cref="MqttConnection.DescribeConnackRejection"/> 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.
|
|
/// </remarks>
|
|
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code)
|
|
=> MqttConnection.DescribeConnackRejection(target, code);
|
|
|
|
/// <summary>
|
|
/// Classifies a thrown connect failure. Precedence mirrors
|
|
/// <see cref="MqttConnection.Classify"/>: 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).
|
|
/// </summary>
|
|
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}",
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.
|
|
}
|
|
}
|
|
}
|