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
This commit is contained in:
@@ -23,12 +23,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Layering note.</b> Unlike every other bespoke browser here, this project references the
|
||||
/// runtime <c>.Driver</c> project (to reuse <c>MqttConnection.BuildClientOptions</c> rather
|
||||
/// than fork the TLS / CA-pin path). That is a known, deliberate exception with a documented
|
||||
/// cost and a documented clean fix — see the comment on that <c>ProjectReference</c> in
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj</c> before copying this project as a
|
||||
/// template.
|
||||
/// <b>Layering note.</b> This project depends on the shared leaves only — <c>.Contracts</c> for
|
||||
/// the Sparkplug wire schema and codec, <c>.Transport</c> for
|
||||
/// <see cref="MqttClientOptionsFactory"/> — and NOT on the runtime <c>.Driver</c> project, which
|
||||
/// it used to reference under a documented exception (Gitea #512). Reuse of the TLS / CA-pin path
|
||||
/// is what that exception bought, and it is preserved: there is still exactly one copy, now in
|
||||
/// <c>.Transport</c>. Do not fork it. Pinned by <c>MqttBrowserLayeringTests</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
@@ -73,7 +73,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
|
||||
var filter = BuildRootFilter(opts);
|
||||
var suffix = BuildBrowseClientIdSuffix();
|
||||
var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
|
||||
var clientOptions = MqttClientOptionsFactory.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
|
||||
|
||||
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
openCts.CancelAfter(OpenBudget(opts));
|
||||
|
||||
+25
-23
@@ -14,34 +14,36 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<!--
|
||||
┌──────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ KNOWN, DELIBERATE EXCEPTION to the .Browser → .Contracts pattern. │
|
||||
│ DO NOT COPY THIS LINE INTO A NEW DRIVER'S BROWSER WITHOUT READING THIS. │
|
||||
└──────────────────────────────────────────────────────────────────────────────────┘
|
||||
The layering exception this project used to carry is GONE (Gitea #512). It referenced the
|
||||
runtime .Driver project, and that reference reached the AdminUI, which references this
|
||||
browser: the AdminUI's build/deploy graph pulled in the whole MQTT driver runtime.
|
||||
|
||||
Every other bespoke browser in this repo (OpcUaClient, Galaxy) references only its own
|
||||
.Contracts project plus its transport package. This one also references the runtime
|
||||
.Driver project. That is a real widening, not a free one, and it was accepted knowingly:
|
||||
MEASURED, not assumed: after the change ZB.MOM.WW.OtOpcUa.Driver.Mqtt.dll, Polly.Core and
|
||||
Serilog are all absent from the AdminUI's output. ZB.MOM.WW.OtOpcUa.Core.dll is still there,
|
||||
because Driver.Sql.Browser ALSO references its runtime driver — the identical violation, in a
|
||||
second driver, and undocumented (this one at least carried a comment). The comment that used
|
||||
to live here claimed this browser was the sole cause of Core reaching the AdminUI; it was not.
|
||||
Tracked separately rather than fixed in passing.
|
||||
|
||||
WHY: the browse CONNECT reuses MqttConnection.BuildClientOptions, which owns the TLS
|
||||
posture, the PEM CA-pin chain validator, the credential handling and the protocol
|
||||
version mapping (~100 lines). A second copy here would be a silent security
|
||||
divergence — browse quietly not honouring a pinned CA while the runtime driver does.
|
||||
The method is pure (no I/O, no network), so the dependency costs nothing at run time.
|
||||
It took TWO extractions to remove, not the one the issue anticipated:
|
||||
|
||||
WHAT IT COSTS: the AdminUI's project graph does NOT otherwise include Core.csproj
|
||||
(only Host.csproj and the runtime Driver.* projects do). Referencing this browser
|
||||
from the AdminUI therefore pulls in Core + Polly.Core + Serilog at build/deploy time.
|
||||
1. MqttClientOptionsFactory (TLS posture, PEM CA-pin chain validator, credentials,
|
||||
protocol-version mapping) moved to the leaf .Transport project below — the fix the
|
||||
comment that used to live here had already prescribed. It cannot live in .Contracts:
|
||||
that project is deliberately transport-free, and BuildClientOptions returns
|
||||
MqttClientOptions, an MQTTnet type.
|
||||
|
||||
THE CLEAN FIX (deferred, not blocked): a small leaf project — say
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport (net10, refs .Contracts + MQTTnet) — holding
|
||||
BuildClientOptions/ConfigureTls/the CA-pin helpers, referenced by BOTH .Driver and
|
||||
.Browser. Note the obvious-looking alternative does NOT work: those members cannot
|
||||
move into .Contracts, because .Contracts is deliberately transport-free (Task 1
|
||||
removed its MQTTnet reference to keep it so) and BuildClientOptions returns
|
||||
MqttClientOptions, an MQTTnet type.
|
||||
2. The Sparkplug PROTOCOL layer — SparkplugTopic/SparkplugMessageType, SparkplugCodec and
|
||||
RebirthRequester — moved to .Contracts, which is where the Tahu wire schema they
|
||||
encode/decode already lived. This half is easy to miss: the browse session decodes
|
||||
births itself, so extracting only the options builder would have left the .Driver
|
||||
reference exactly where it was.
|
||||
|
||||
Whatever else changes here, there must stay exactly ONE copy of the TLS/CA-pin logic. The
|
||||
failure mode of a second copy is not a build break but a silent security divergence — browse
|
||||
quietly not honouring a pinned CA while the runtime driver does.
|
||||
-->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MQTTnet;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the MQTTnet client options for every broker connection this repo opens — the runtime
|
||||
/// driver's, the browse session's and the Test-connect probe's alike.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is its own type in its own assembly (Gitea #512).</b> It owns the security posture of
|
||||
/// an MQTT connection: whether TLS is on, whether an untrusted certificate is tolerated, and — when a
|
||||
/// PEM CA is pinned — the custom chain validator that replaces the OS trust anchor. Both the runtime
|
||||
/// driver and the browse session must connect with <em>identical</em> semantics, and the failure mode
|
||||
/// of a second copy is not a crash but a silent divergence: browse quietly not honouring a pinned CA
|
||||
/// while the driver does. Previously this lived on <c>MqttConnection</c> and <c>.Browser</c> took a
|
||||
/// documented layering exception to reach it; the exception is gone, and this is the one copy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Pure.</b> No I/O, no network, nothing read from disk — so it is safe to call on a
|
||||
/// config-validation path. A configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read
|
||||
/// lazily by the returned validation callback at handshake time, not here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttClientOptionsFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
|
||||
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
|
||||
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
|
||||
/// returned certificate-validation callback at handshake time, not here.
|
||||
/// </summary>
|
||||
/// <param name="options">Broker connection settings.</param>
|
||||
/// <param name="clientIdSuffix">
|
||||
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
|
||||
/// the configured identity without colliding with the driver's own client id. When both are
|
||||
/// empty MQTTnet generates a random client id.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
|
||||
public static MqttClientOptions BuildClientOptions(
|
||||
MqttDriverOptions options,
|
||||
string? clientIdSuffix,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var builder = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(options.Host, options.Port)
|
||||
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
|
||||
.WithCleanSession(options.CleanSession)
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
|
||||
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
|
||||
|
||||
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
builder = builder.WithClientId(clientId);
|
||||
}
|
||||
|
||||
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
|
||||
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
|
||||
if (!string.IsNullOrEmpty(options.Username))
|
||||
{
|
||||
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
||||
}
|
||||
|
||||
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
|
||||
{
|
||||
if (!options.UseTls)
|
||||
{
|
||||
tls.UseTls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
tls.UseTls(true)
|
||||
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
|
||||
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
|
||||
.WithTargetHost(options.Host);
|
||||
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
|
||||
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
|
||||
tls.WithAllowUntrustedCertificates(true)
|
||||
.WithIgnoreCertificateChainErrors(true)
|
||||
.WithCertificateValidationHandler(static _ => true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||||
{
|
||||
// No pin: MQTTnet's default handler validates against the OS trust store.
|
||||
return;
|
||||
}
|
||||
|
||||
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
|
||||
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
|
||||
/// (i.e. during the TLS handshake) so that this — and therefore
|
||||
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
|
||||
/// up to the pinned CA rejects the certificate: fail closed.
|
||||
/// </summary>
|
||||
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
|
||||
string caCertificatePath,
|
||||
ILogger? logger)
|
||||
{
|
||||
var trustedRoots = new Lazy<X509Certificate2Collection?>(
|
||||
() => LoadPemCa(caCertificatePath, logger),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
|
||||
}
|
||||
|
||||
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(caCertificatePath);
|
||||
if (collection.Count != 0)
|
||||
{
|
||||
return collection;
|
||||
}
|
||||
|
||||
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ValidateAgainstPinnedCa(
|
||||
MqttClientCertificateValidationEventArgs args,
|
||||
X509Certificate2Collection? trustedRoots,
|
||||
string caCertificatePath,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (trustedRoots is null || trustedRoots.Count == 0)
|
||||
{
|
||||
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
|
||||
// mismatch, an absent certificate — remains fatal.
|
||||
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
|
||||
{
|
||||
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args.Certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var presented = args.Certificate as X509Certificate2;
|
||||
X509Certificate2? owned = null;
|
||||
if (presented is null)
|
||||
{
|
||||
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
||||
presented = owned;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
|
||||
|
||||
// Revocation is deliberately not checked: the pin exists precisely for self-issued
|
||||
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
|
||||
// would fail every such chain. The pin itself is the revocation story — remove the CA.
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
|
||||
// Seed the intermediates the broker presented during the handshake. A leaf signed by
|
||||
// an intermediate that chains up to the pinned root is a common broker topology, and
|
||||
// that intermediate arrives on the wire rather than being installed locally — without
|
||||
// it in the candidate pool a legitimate trust path simply cannot be built.
|
||||
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
|
||||
// self-signed cert smuggled in here cannot become an anchor.
|
||||
// Both sources are read: platforms differ in whether the peer-supplied intermediates
|
||||
// land in the incoming chain's elements, its ExtraStore, or both.
|
||||
if (args.Chain is not null)
|
||||
{
|
||||
foreach (var element in args.Chain.ChainElements)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
|
||||
}
|
||||
|
||||
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
|
||||
}
|
||||
|
||||
if (chain.Build(presented))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
logger?.LogError(
|
||||
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
|
||||
caCertificatePath,
|
||||
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
|
||||
return false;
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
|
||||
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
|
||||
// it here and refuse: an unverifiable certificate is a rejected certificate.
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
|
||||
caCertificatePath);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
owned?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
|
||||
=> version switch
|
||||
{
|
||||
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
|
||||
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
|
||||
};
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
The leaf project the .Browser csproj's "KNOWN, DELIBERATE EXCEPTION" comment called for, now
|
||||
that the exception is gone (Gitea #512).
|
||||
|
||||
It exists for exactly one reason: MqttClientOptionsFactory owns the TLS posture, the PEM CA-pin
|
||||
chain validator, the credential handling and the protocol-version mapping — and BOTH the runtime
|
||||
driver and the browse session have to open a broker connection with identical semantics. A second
|
||||
copy would be a silent security divergence: browse quietly not honouring a pinned CA while the
|
||||
driver does.
|
||||
|
||||
Why not .Contracts, the obvious home: .Contracts is deliberately transport-free (it carries the
|
||||
Sparkplug wire schema + codec and has no MQTTnet reference), and BuildClientOptions returns
|
||||
MqttClientOptions — an MQTTnet type. Putting it there would hand MQTTnet to every .Contracts
|
||||
consumer, including ControlPlane, which has no business with a broker client.
|
||||
|
||||
Kept deliberately thin: this holds connection POLICY only, and nothing that connects. The live
|
||||
client, the reconnect loop and the subscription bookkeeping stay in .Driver.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -568,7 +568,7 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
|
||||
MqttClientOptions clientOptions;
|
||||
try
|
||||
{
|
||||
clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger);
|
||||
clientOptions = MqttClientOptionsFactory.BuildClientOptions(_options, clientIdSuffix: null, _logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1345,217 +1345,4 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
|
||||
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
|
||||
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
|
||||
/// returned certificate-validation callback at handshake time, not here.
|
||||
/// </summary>
|
||||
/// <param name="options">Broker connection settings.</param>
|
||||
/// <param name="clientIdSuffix">
|
||||
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
|
||||
/// the configured identity without colliding with the driver's own client id. When both are
|
||||
/// empty MQTTnet generates a random client id.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
|
||||
public static MqttClientOptions BuildClientOptions(
|
||||
MqttDriverOptions options,
|
||||
string? clientIdSuffix,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var builder = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(options.Host, options.Port)
|
||||
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
|
||||
.WithCleanSession(options.CleanSession)
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
|
||||
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
|
||||
|
||||
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
builder = builder.WithClientId(clientId);
|
||||
}
|
||||
|
||||
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
|
||||
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
|
||||
if (!string.IsNullOrEmpty(options.Username))
|
||||
{
|
||||
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
||||
}
|
||||
|
||||
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
|
||||
{
|
||||
if (!options.UseTls)
|
||||
{
|
||||
tls.UseTls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
tls.UseTls(true)
|
||||
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
|
||||
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
|
||||
.WithTargetHost(options.Host);
|
||||
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
|
||||
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
|
||||
tls.WithAllowUntrustedCertificates(true)
|
||||
.WithIgnoreCertificateChainErrors(true)
|
||||
.WithCertificateValidationHandler(static _ => true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
||||
{
|
||||
// No pin: MQTTnet's default handler validates against the OS trust store.
|
||||
return;
|
||||
}
|
||||
|
||||
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
|
||||
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
|
||||
/// (i.e. during the TLS handshake) so that this — and therefore
|
||||
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
|
||||
/// up to the pinned CA rejects the certificate: fail closed.
|
||||
/// </summary>
|
||||
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
|
||||
string caCertificatePath,
|
||||
ILogger? logger)
|
||||
{
|
||||
var trustedRoots = new Lazy<X509Certificate2Collection?>(
|
||||
() => LoadPemCa(caCertificatePath, logger),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
|
||||
}
|
||||
|
||||
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(caCertificatePath);
|
||||
if (collection.Count != 0)
|
||||
{
|
||||
return collection;
|
||||
}
|
||||
|
||||
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ValidateAgainstPinnedCa(
|
||||
MqttClientCertificateValidationEventArgs args,
|
||||
X509Certificate2Collection? trustedRoots,
|
||||
string caCertificatePath,
|
||||
ILogger? logger)
|
||||
{
|
||||
if (trustedRoots is null || trustedRoots.Count == 0)
|
||||
{
|
||||
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
|
||||
// mismatch, an absent certificate — remains fatal.
|
||||
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
|
||||
{
|
||||
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args.Certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var presented = args.Certificate as X509Certificate2;
|
||||
X509Certificate2? owned = null;
|
||||
if (presented is null)
|
||||
{
|
||||
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
||||
presented = owned;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
|
||||
|
||||
// Revocation is deliberately not checked: the pin exists precisely for self-issued
|
||||
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
|
||||
// would fail every such chain. The pin itself is the revocation story — remove the CA.
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
|
||||
// Seed the intermediates the broker presented during the handshake. A leaf signed by
|
||||
// an intermediate that chains up to the pinned root is a common broker topology, and
|
||||
// that intermediate arrives on the wire rather than being installed locally — without
|
||||
// it in the candidate pool a legitimate trust path simply cannot be built.
|
||||
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
|
||||
// self-signed cert smuggled in here cannot become an anchor.
|
||||
// Both sources are read: platforms differ in whether the peer-supplied intermediates
|
||||
// land in the incoming chain's elements, its ExtraStore, or both.
|
||||
if (args.Chain is not null)
|
||||
{
|
||||
foreach (var element in args.Chain.ChainElements)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
|
||||
}
|
||||
|
||||
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
|
||||
}
|
||||
|
||||
if (chain.Build(presented))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
logger?.LogError(
|
||||
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
|
||||
caCertificatePath,
|
||||
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
|
||||
return false;
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
|
||||
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
|
||||
// it here and refuse: an unverifiable certificate is a rejected certificate.
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
|
||||
caCertificatePath);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
owned?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
|
||||
=> version switch
|
||||
{
|
||||
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
|
||||
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -939,7 +939,7 @@ public sealed class MqttDriver
|
||||
// ONLINE half without the Will is strictly worse than shipping neither: a crashed node
|
||||
// would leave a retained ONLINE asserting forever that a dead host is alive, which is the
|
||||
// exact state the mechanism exists to prevent. The Will belongs in
|
||||
// MqttConnection.BuildClientOptions; until it is there this flag stays inert and said so.
|
||||
// MqttClientOptionsFactory.BuildClientOptions; until it is there this flag stays inert and said so.
|
||||
_logger?.LogWarning(
|
||||
"MQTT driver '{DriverId}': Sparkplug actAsPrimaryHost is set but primary-host STATE "
|
||||
+ "publishing is not implemented — this driver observes STATE and never publishes one. "
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Reuses <see cref="MqttConnection.BuildClientOptions"/></b> for the connect (TLS,
|
||||
/// <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
|
||||
@@ -101,7 +101,7 @@ public sealed class MqttDriverProbe : IDriverProbe
|
||||
MqttClientOptions clientOptions;
|
||||
try
|
||||
{
|
||||
clientOptions = MqttConnection.BuildClientOptions(
|
||||
clientOptions = MqttClientOptionsFactory.BuildClientOptions(
|
||||
probeOptions,
|
||||
ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8],
|
||||
NullLogger.Instance);
|
||||
|
||||
@@ -1038,7 +1038,7 @@ public sealed class SparkplugIngestor
|
||||
/// while their host is offline. Shipping the ONLINE half without the Will is strictly worse
|
||||
/// than shipping neither: an OtOpcUa node that crashes would leave a retained ONLINE asserting
|
||||
/// forever that a dead host is alive, which is precisely the state the mechanism exists to
|
||||
/// prevent. The Will has to be set inside <c>MqttConnection.BuildClientOptions</c> — a
|
||||
/// prevent. The Will has to be set inside <c>MqttClientOptionsFactory.BuildClientOptions</c> — a
|
||||
/// different file, a different task — so this task implements the observation half and
|
||||
/// <see cref="MqttDriver"/> warns when the flag is set. See the design doc §7/§8.
|
||||
/// </para>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user