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:
+21
-3
@@ -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");
|
||||
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+29
@@ -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>
|
||||
|
||||
+2
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user