73132b7d78
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.
136 lines
7.1 KiB
C#
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");
|
|
}
|
|
}
|