Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs
T
Joseph Doherty 73132b7d78 fix(opcuaclient): #910 name the per-endpoint connect budget; widen it in the #816 host-name tests
Cause: the client-side PerEndpointConnectTimeout (3 s), not opc-plc. The
validate:null case is the first test the assembly runs, so it pays the
cold-process cost of the first Basic256Sha256 connect: loading the fresh
throwaway certificate, validating the server's, and the asymmetric handshake.
In the G2 full-solution run that local work used up the budget. The opc-plc
log at 13:13:50-53Z shows the discovery channel closing at 50.709, then nothing
from the client for 3 s, then OpenSecureChannel answered at 53.775 and the
client closing the channel 48 ms later. The SDK reports a wait cancelled by
that budget as BadRequestInterrupted. opc-plc auto-accepted the certificate
(--aa, "Trusting certificate ... command line option") with no reject and no
store round-trip.

- Driver: a per-endpoint budget expiry now surfaces as TimeoutException naming
  PerEndpointConnectTimeout instead of BadRequestInterrupted or a bare
  TaskCanceledException. There is no behaviour change. Unit test added
  against a silent TCP listener.
- Test: the ServerHostNameValidationTests secure options use a 15 s budget,
  the same as the browser integration tests' 10 s. Connect latency is not
  what these tests check.
- docs/drivers/OpcUaClient.md: documents what the budget covers.
2026-11-13 12:42:45 -05:00

136 lines
7.1 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Gitea #816 — does the driver check the server certificate's host name? Against the plain opc-plc fixture, whose
/// certificate names only its container hostname (e.g. <c>DNS:1fc1de3fcb21</c>) while the driver dials the docker
/// host's address, so a host check MUST fail if it runs at all.
/// <para>Before #816 the driver called the SDK's 8-argument <c>DefaultSessionFactory.CreateAsync</c>, which hard-codes
/// <c>checkDomain: false</c>: the mismatch was never noticed. Now <c>certificateValidation.validateServerHostName</c>
/// decides; unset keeps the old behaviour.</para>
/// <para>Each test trusts the server's certificate in a THROWAWAY PKI (the driver's <c>PkiRootOverride</c>) with
/// auto-accept OFF — auto-accept also accepts a host-name mismatch and would hide the check. The certificate is
/// taken from the server's own GetEndpoints answer: what is under test is the host check, not how trust was
/// established.</para>
/// </summary>
[Collection(OpcPlcCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc")]
public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposable
{
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), "otopcua-816-" + Guid.NewGuid().ToString("N"));
/// <inheritdoc />
public void Dispose()
{
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort temp cleanup */ }
}
private OpcUaClientDriverOptions SecureOptions(bool? validateHostName) => OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = validateHostName },
// Gitea #910: the default 3 s per-endpoint budget covers client-side work too — loading the fresh throwaway
// certificate, validating the server's, the first Basic256Sha256 handshake in a cold test process. The first
// test in this assembly (the validate:null case) paid that cold cost, and in the full-solution run it ran out:
// opc-plc answered OpenSecureChannel at once, but the client reached it only after 3 s of local work and
// cancelled 48 ms later (BadRequestInterrupted). What is under test is the host check, not connect latency.
PerEndpointConnectTimeout = TimeSpan.FromSeconds(15),
};
/// <summary>Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI.</summary>
private async Task TrustServerCertificateAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa #816 trust",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration(),
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
DisableHiResClock = true,
};
using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(sim.EndpointUrl), DiagnosticsMasks.None, ct);
var endpoints = await discovery.GetEndpointsAsync(default, ct);
var secure = endpoints.ToList().First(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
var der = secure.ServerCertificate.ToArray();
der.ShouldNotBeEmpty("the secure endpoint must carry the server certificate");
var certs = Path.Combine(_pkiRoot, "trusted", "certs");
Directory.CreateDirectory(certs);
await File.WriteAllBytesAsync(Path.Combine(certs, "opc-plc.der"), der, ct);
}
private async Task<OpcUaClientDriver> InitializeAsync(bool? validateHostName, string id, CancellationToken ct)
{
var drv = new OpcUaClientDriver(SecureOptions(validateHostName), driverInstanceId: id) { PkiRootOverride = _pkiRoot };
try
{
await drv.InitializeAsync("{}", ct);
return drv;
}
catch
{
await drv.DisposeAsync();
throw;
}
}
[Theory]
[InlineData(null)] // unset = the pre-#816 behaviour every existing deployment has
[InlineData(false)] // the explicit NAT / IP-addressed opt-out
public async Task Without_host_name_validation_a_trusted_certificate_for_another_host_connects(bool? validate)
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
await using var drv = await InitializeAsync(validate, $"opcua-816-off-{validate}", ct);
var snapshots = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted");
}
/// <summary>
/// Review of #816 (reproduced live): SDK 2.0.1 also checks the host at channel creation, against the certificate
/// in the GetEndpoints reply — which opc-plc sends on its None endpoint too — before any security-mode test. The
/// driver must not pass checkDomain for a None session, or the flag refuses an unsecured connect.
/// </summary>
[Fact]
public async Task With_host_name_validation_on_a_None_session_still_connects()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = true },
};
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-816-none") { PkiRootOverride = _pkiRoot };
await drv.InitializeAsync("{}", ct);
(await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct))[0].StatusCode.ShouldBe(0u);
}
[Fact]
public async Task With_host_name_validation_the_certificate_is_checked_against_the_configured_host()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
var ex = await Should.ThrowAsync<Exception>(() => InitializeAsync(true, "opcua-816-on", ct));
TestContext.Current.TestOutputHelper?.WriteLine($"refused: {ex}");
// The driver's failover aggregate keeps per-endpoint failures as text. The SDK message names the host it
// checked: the CONFIGURED one (the endpoint URL was rewritten to it), not whatever the server advertised.
var configuredHost = new Uri(sim.EndpointUrl).IdnHost;
var text = ex.ToString();
text.ShouldContain($"The domain '{configuredHost}' is not listed in the server certificate");
text.ShouldContain("validateServerHostName=false", Case.Sensitive, "the refusal must name the opt-out");
}
}