using Opc.Ua; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// 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. DNS:1fc1de3fcb21) while the driver dials the docker /// host's address, so a host check MUST fail if it runs at all. /// Before #816 the driver called the SDK's 8-argument DefaultSessionFactory.CreateAsync, which hard-codes /// checkDomain: false: the mismatch was never noticed. Now certificateValidation.validateServerHostName /// decides; unset keeps the old behaviour. /// Each test trusts the server's certificate in a THROWAWAY PKI (the driver's PkiRootOverride) 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. /// [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")); /// 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 }, }; /// Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI. 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 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"); } /// /// 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. /// [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(() => 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"); } }