feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)

#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
This commit is contained in:
Joseph Doherty
2026-11-11 01:33:00 -05:00
parent bde6cce2fc
commit ae91e6b7f7
30 changed files with 648 additions and 59 deletions
@@ -35,6 +35,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
private readonly ISecretResolver? _secretResolver;
private readonly string? _pkiRootOverride;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
@@ -49,6 +50,18 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
_secretResolver = secretResolver;
}
/// <summary>
/// Test seam (Gitea #818): a browser whose PKI lives under <paramref name="pkiRoot"/> instead of the shared
/// <c>%LocalAppData%/OtOpcUa/adminui-browse-pki</c>, so an integration test can trust a fixture's public
/// certificate in a throwaway store without touching the operator's browse trust list.
/// </summary>
/// <param name="pkiRoot">Directory holding the <c>own</c>/<c>trusted</c>/<c>issuers</c>/<c>rejected</c> stores.</param>
internal OpcUaClientDriverBrowser(string pkiRoot) : this()
{
ArgumentException.ThrowIfNullOrWhiteSpace(pkiRoot);
_pkiRootOverride = pkiRoot;
}
/// <inheritdoc />
// Sourced from the shared DriverTypeNames set rather than the hand-written literal
// this line used to carry. The value is unchanged; the constant is what
@@ -82,7 +95,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
"driver's PKI store instead.",
endpoint);
var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var app = await BuildBrowseAppConfigurationAsync(_pkiRootOverride, cancellationToken).ConfigureAwait(false);
ownedApplication = app;
var appConfig = app.ApplicationConfiguration!;
@@ -101,6 +114,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
// Gitea #816: the same host-name policy as the deployed driver (the SDK's overload without this
// argument hard-codes false), checked against the configured host the endpoint was rewritten to.
checkDomain: opts.CertificateValidation.EffectiveValidateServerHostName,
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
@@ -143,8 +159,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
/// trust decisions don't leak into the deployed driver's cert store.
/// </summary>
/// <param name="pkiRootOverride">Test-only PKI root; <c>null</c> = the shared browse PKI.</param>
/// <param name="ct">Cancellation token for the configuration build.</param>
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(CancellationToken ct)
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(
string? pkiRootOverride, CancellationToken ct)
{
// The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK
// internals off any logging provider.
@@ -154,7 +172,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
ApplicationType = ApplicationType.Client,
};
var pkiRoot = Path.Combine(
var pkiRoot = pkiRootOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "adminui-browse-pki");
@@ -19,5 +19,6 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests"/>
</ItemGroup>
</Project>
@@ -231,6 +231,35 @@ public sealed record OpcUaCertificateValidationOptions
/// operator-managed CRL set. <c>null</c>/empty (default) = <c>%LocalAppData%/OtOpcUa/pki/issuers</c>.
/// </summary>
public string? IssuerStorePath { get; init; }
/// <summary>
/// The value an unset <see cref="ValidateServerHostName"/> resolves to. <c>false</c> keeps the behaviour every
/// deployment had before Gitea #816 (the SDK never checked the host name for this driver family).
/// </summary>
public const bool DefaultValidateServerHostName = false;
/// <summary>
/// Gitea #816 — whether the server certificate must list the host the operator CONFIGURED (the endpoint URL's
/// host, which the shared <see cref="OpcUaEndpointUrlRewriter"/> keeps in place of the server-advertised one)
/// among its DNS / IP subject-alternative names. A mismatch refuses the session with
/// <c>BadCertificateHostNameInvalid</c>.
/// <list type="bullet">
/// <item><c>null</c> (default) = <see cref="DefaultValidateServerHostName"/> (off).</item>
/// <item><c>true</c> = validate. Worth it when trust comes from a CA (a trusted issuer vouches for every
/// certificate it signed, so only the host check ties the certificate to THIS server); adds little when the
/// server's own self-signed certificate is pinned in the trusted-peer store.</item>
/// <item><c>false</c> = explicit opt-out — the NAT / port-publish / IP-addressed case, where the server's
/// certificate names its private host and the reachable address is something else.</item>
/// </list>
/// Only applies to secured sessions (a <c>None</c> channel validates no server certificate at all), and
/// <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> accepts a host-name mismatch like any other
/// certificate error.
/// </summary>
public bool? ValidateServerHostName { get; init; }
/// <summary><see cref="ValidateServerHostName"/> with the default applied.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool EffectiveValidateServerHostName => ValidateServerHostName ?? DefaultValidateServerHostName;
}
/// <summary>OPC UA message security mode.</summary>
@@ -581,7 +581,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}{HostNameRefusalHint(ex)}");
}
}
@@ -726,6 +726,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private void WriteLifecycleHealth(long generation, Func<DriverHealth, DriverHealth> next) =>
UpdateHealth(generation, next);
/// <summary>
/// Test seam (Gitea #816): when set, the PKI stores live under this directory instead of the shared
/// <c>%LocalAppData%/OtOpcUa/pki</c>, so an integration test can trust a fixture's certificate in a throwaway
/// store with <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> off (auto-accept also accepts a
/// host-name mismatch, which would hide the check under test). Set before <c>InitializeAsync</c>.
/// </summary>
internal string? PkiRootOverride { get; set; }
/// <summary>
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
@@ -747,7 +755,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
};
certValidation.Application = app;
var pkiRoot = Path.Combine(
var pkiRoot = PkiRootOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "pki");
@@ -1051,10 +1059,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
// Gitea #816: the SDK's 8-argument CreateAsync hard-codes checkDomain:false, so before #816 the server
// certificate's host name was never checked. The check (when enabled) runs against endpoint.EndpointUrl —
// the CONFIGURED host, because SelectMatching rewrote the advertised one.
var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync(
appConfig,
endpoint,
false, // updateBeforeConnect
_options.CertificateValidation.EffectiveValidateServerHostName, // checkDomain (#816)
_options.SessionName,
(uint)_options.SessionTimeout.TotalMilliseconds,
identity,
@@ -1085,6 +1097,19 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return OpcUaEndpointSelection.SelectMatching(all.ToList(), endpointUrl, policy, mode);
}
/// <summary>
/// Gitea #816 — the operator-facing remedy appended to a <c>BadCertificateHostNameInvalid</c> connect failure (only
/// reachable with <c>certificateValidation.validateServerHostName</c> on). The SDK's own text names the host it
/// checked but not the knob.
/// </summary>
/// <param name="ex">The per-endpoint connect failure.</param>
/// <returns>The hint, or an empty string for any other failure.</returns>
internal static string HostNameRefusalHint(Exception ex) =>
ex is ServiceResultException sre && sre.StatusCode == StatusCodes.BadCertificateHostNameInvalid
? " (the server certificate does not name the configured endpoint host; add that host to the certificate, " +
"connect by a name it lists, or — behind NAT / a port publish — set certificateValidation.validateServerHostName=false)"
: string.Empty;
/// <summary>Delegates to the shared <see cref="OpcUaEndpointUrlRewriter.Rewrite"/> (Gitea #676 / #806).</summary>
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
/// <param name="configuredUrl">The URL the driver was configured with (the one GetEndpoints was sent to).</param>
@@ -28,6 +28,8 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
<!-- Gitea #816: the host-name tests trust the fixture certificate in a throwaway PKI (PkiRootOverride). -->
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests"/>
<!-- Gitea #656: the native-alarm re-statement end-to-end tests drive this driver into the real DriverHostActor. -->
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Runtime.Tests"/>
<!-- Gitea #670 review M1: the wire test deletes the driver's subscription server-side through its own session. -->