a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
ConfigurationBinder appends configured collection items to an existing list, so the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op: every deployment that listed only the secure profiles still advertised an unsigned/unencrypted None endpoint. EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles() applies the unchanged three-profile default only when nothing is configured. A set-but-empty list fails startup validation and BuildSecurityPolicies no longer falls back to a None endpoint on empty input (it throws). Sweep of all options classes found no other non-empty pre-filled collection default. Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a booted-server wire test (no None endpoint, None connection refused with BadSecurityPolicyRejected, secure connection works; default case is the control). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
154 lines
7.7 KiB
C#
154 lines
7.7 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#879 over the wire. The options come from a real <c>ConfigurationBinder</c> bind (the path the Host
|
|
/// uses), so a regression to a pre-filled list shows up as an advertised <c>None</c> endpoint that admits a
|
|
/// session. The default case is the positive control: the same probes must find and use <c>None</c> there.
|
|
/// </summary>
|
|
public sealed class SecurityProfilesReplaceWireTests
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
[Fact]
|
|
public async Task Configuring_only_the_secure_profiles_removes_the_None_endpoint_and_refuses_a_None_connection()
|
|
{
|
|
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>
|
|
{
|
|
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign",
|
|
["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt",
|
|
}, Ct);
|
|
|
|
var modes = (await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray();
|
|
modes.ShouldBe([MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
|
|
|
|
// Positive control: the secure endpoint really is usable, so the refusal below is about None, not about the rig.
|
|
using (var secure = await rig.ConnectAsync(MessageSecurityMode.SignAndEncrypt, Ct))
|
|
secure.Connected.ShouldBeTrue();
|
|
|
|
var refusal = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(MessageSecurityMode.None, Ct));
|
|
refusal.StatusCode.ShouldBe(StatusCodes.BadSecurityPolicyRejected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nothing_configured_still_advertises_all_three_and_a_None_connection_is_admitted()
|
|
{
|
|
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>(), Ct);
|
|
|
|
(await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray()
|
|
.ShouldBe([MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
|
|
using var none = await rig.ConnectAsync(MessageSecurityMode.None, Ct);
|
|
none.Connected.ShouldBeTrue();
|
|
}
|
|
|
|
private sealed class Rig : IAsyncDisposable
|
|
{
|
|
private readonly string _pki = TestClientSecurity.AllocatePkiRoot();
|
|
private readonly string _clientPki = TestClientSecurity.AllocatePkiRoot();
|
|
private OpcUaApplicationHost _host = null!;
|
|
private int _port;
|
|
|
|
public static async Task<Rig> StartAsync(Dictionary<string, string?> values, CancellationToken ct)
|
|
{
|
|
var rig = new Rig();
|
|
var section = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
try
|
|
{
|
|
(rig._host, _) = await OpcUaHostStart.StartAsync(port =>
|
|
{
|
|
var options = new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Sec879",
|
|
ApplicationUri = $"urn:OtOpcUa.Sec879:{Guid.NewGuid():N}",
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = rig._pki,
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
};
|
|
section.GetSection("OpcUa").Bind(options); // the real binder, exactly what AddValidatedOptions does
|
|
options.OpcUaPort = port;
|
|
rig._port = port;
|
|
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
|
}, () => new OtOpcUaSdkServer(), ct);
|
|
return rig;
|
|
}
|
|
catch { await rig.DisposeAsync(); throw; }
|
|
}
|
|
|
|
private string Url => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
|
|
|
|
private (ApplicationConfiguration Config, ApplicationInstance App)? _client;
|
|
|
|
private async Task<(ApplicationConfiguration Config, ApplicationInstance App)> ClientAsync(CancellationToken ct)
|
|
{
|
|
if (_client is { } cached) return cached;
|
|
var security = TestClientSecurity.Build(_clientPki);
|
|
security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.Sec879Client";
|
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
var config = new ApplicationConfiguration(telemetry)
|
|
{
|
|
ApplicationName = "OtOpcUa.Sec879Client",
|
|
ApplicationUri = $"urn:OtOpcUa.Sec879Client.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = security,
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
var app = new ApplicationInstance(telemetry) { ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client };
|
|
app.ApplicationConfiguration = config;
|
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct);
|
|
_client = (config, app);
|
|
return (config, app);
|
|
}
|
|
|
|
public async Task<MessageSecurityMode[]> GetEndpointModesAsync(CancellationToken ct)
|
|
{
|
|
var (config, _) = await ClientAsync(ct);
|
|
using var discovery = await DiscoveryClient.CreateAsync(config, new Uri(Url), DiagnosticsMasks.None, ct);
|
|
var endpoints = await discovery.GetEndpointsAsync(default, ct);
|
|
return endpoints.ToArray()!.Select(e => e.SecurityMode).ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens an anonymous session on the endpoint of the requested mode. For <c>None</c> the endpoint
|
|
/// description is synthesised rather than discovered, so a server that no longer advertises it is
|
|
/// probed with exactly what a downgrading client would send.
|
|
/// </summary>
|
|
public async Task<ISession> ConnectAsync(MessageSecurityMode mode, CancellationToken ct)
|
|
{
|
|
var (config, _) = await ClientAsync(ct);
|
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
// Always start from a discovered secure endpoint (its token policies are what the server really sent);
|
|
// None is a clone of it with the mode and policy downgraded, i.e. what a downgrading client sends.
|
|
var description = await CoreClientUtils.SelectEndpointAsync(config, Url, true, telemetry, ct)
|
|
?? throw new InvalidOperationException("No secure endpoint.");
|
|
if (mode == MessageSecurityMode.None)
|
|
{
|
|
description = (EndpointDescription)description.Clone();
|
|
description.SecurityMode = MessageSecurityMode.None;
|
|
description.SecurityPolicyUri = SecurityPolicies.None;
|
|
description.ServerCertificate = default;
|
|
}
|
|
else description.SecurityMode.ShouldBe(mode);
|
|
return await new DefaultSessionFactory(telemetry).CreateAsync(
|
|
config, new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(config)),
|
|
updateBeforeConnect: false, checkDomain: false, sessionName: "Sec879", sessionTimeout: 60_000,
|
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_client is { } client) await client.App.DisposeAsync();
|
|
if (_host is not null) await _host.DisposeAsync();
|
|
foreach (var dir in new[] { _pki, _clientPki })
|
|
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
|
}
|
|
}
|
|
}
|