Compare commits
6 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79c5f3008 | ||
| a5299a2fee | |||
|
|
a65215684c | ||
| 82f2dfcfa3 | |||
|
|
0433d3a35e | ||
| 141673fc80 |
@@ -27,8 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private KeepAliveEventHandler? _keepAliveHandler;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
||||
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
||||
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
||||
@@ -62,15 +74,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// requested security policy/mode so the driver doesn't have to hand-validate.
|
||||
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
||||
// entirely and is the typical dev-bench configuration.
|
||||
var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
|
||||
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
|
||||
// parameter. Passing null is valid — the SDK falls through to its built-in default
|
||||
// trace sink. Plumbing a telemetry context through every driver surface is out of
|
||||
// scope; the driver emits its own logs via the health surface anyway.
|
||||
var selected = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, _options.EndpointUrl, useSecurity,
|
||||
telemetry: null!,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
var selected = await SelectMatchingEndpointAsync(
|
||||
appConfig, _options.EndpointUrl, _options.SecurityPolicy, _options.SecurityMode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
@@ -84,8 +90,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
_options.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
||||
OpcUaAuthType.Certificate => throw new NotSupportedException(
|
||||
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
|
||||
OpcUaAuthType.Certificate => BuildCertificateIdentity(_options),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
@@ -110,8 +115,21 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
|
||||
// Wire the session's keep-alive channel into HostState. OPC UA keep-alives are
|
||||
// authoritative for session liveness: the SDK pings on KeepAliveInterval and sets
|
||||
// KeepAliveStopped when N intervals elapse without a response. That's strictly
|
||||
// better than a driver-side polling probe — no extra round-trip, no duplicate
|
||||
// semantic.
|
||||
_keepAliveHandler = (_, e) =>
|
||||
{
|
||||
var healthy = !ServiceResult.IsBad(e.Status);
|
||||
TransitionTo(healthy ? HostState.Running : HostState.Stopped);
|
||||
};
|
||||
session.KeepAlive += _keepAliveHandler;
|
||||
|
||||
Session = session;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
TransitionTo(HostState.Running);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -206,6 +224,100 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||||
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||||
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||||
/// </summary>
|
||||
private static async Task<EndpointDescription> SelectMatchingEndpointAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
OpcUaSecurityPolicy policy,
|
||||
OpcUaSecurityMode mode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// GetEndpoints returns everything the server advertises; policy + mode filter is
|
||||
// applied client-side so the selection is explicit and fails loudly if the operator
|
||||
// asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync
|
||||
// is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are
|
||||
// all deprecated.
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
|
||||
var match = all.FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all
|
||||
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||||
/// Used when the remote server's endpoint advertises Certificate-type user tokens.
|
||||
/// Fails fast if the path is missing, the file doesn't exist, or the certificate
|
||||
/// lacks a private key (the private key is required to sign the user-token
|
||||
/// challenge during session activation).
|
||||
/// </summary>
|
||||
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
if (!System.IO.File.Exists(options.UserCertificatePath))
|
||||
throw new System.IO.FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
options.UserCertificatePath);
|
||||
|
||||
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
||||
// since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the
|
||||
// password through verbatim; PEM files with external keys fall back to
|
||||
// LoadCertificateFromFile which picks up the adjacent .key if present.
|
||||
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||
|
||||
if (!cert.HasPrivateKey)
|
||||
throw new InvalidOperationException(
|
||||
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
||||
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
||||
|
||||
return new UserIdentity(cert);
|
||||
}
|
||||
|
||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
||||
};
|
||||
|
||||
private static string ShortPolicyName(string policyUri) =>
|
||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -214,10 +326,29 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
||||
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
||||
// whether or not the wire-side delete succeeds since the local handles are useless
|
||||
// after close anyway.
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
{
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (_keepAliveHandler is not null && Session is not null)
|
||||
{
|
||||
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
}
|
||||
_keepAliveHandler = null;
|
||||
|
||||
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
try { Session?.Dispose(); } catch { }
|
||||
Session = null;
|
||||
|
||||
TransitionTo(HostState.Unknown);
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -484,6 +615,131 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var handle = new OpcUaSubscriptionHandle(id);
|
||||
|
||||
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
||||
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
||||
// bandwidth on every subscription create.
|
||||
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
||||
? 50
|
||||
: (int)publishingInterval.TotalMilliseconds;
|
||||
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-sub-{id}",
|
||||
PublishingInterval = intervalMs,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var fullRef in fullReferences)
|
||||
{
|
||||
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
|
||||
// The tag string is routed through MonitoredItem.Handle so the Notification
|
||||
// handler can identify which tag changed without an extra lookup.
|
||||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
||||
{
|
||||
DisplayName = fullRef,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
MonitoringMode = MonitoringMode.Reporting,
|
||||
SamplingInterval = intervalMs,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
})
|
||||
{
|
||||
Handle = fullRef,
|
||||
};
|
||||
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||||
subscription.AddItem(item);
|
||||
}
|
||||
|
||||
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_subscriptions[id] = new RemoteSubscription(subscription, handle);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not OpcUaSubscriptionHandle h) return;
|
||||
if (!_subscriptions.TryRemove(h.Id, out var rs)) return;
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort — the subscription may already be gone on reconnect */ }
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
|
||||
{
|
||||
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
|
||||
// subscriptions; extract its DataValue. The Handle property carries our tag string.
|
||||
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
||||
var dv = mn.Value;
|
||||
if (dv is null) return;
|
||||
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
||||
var snapshot = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
||||
}
|
||||
|
||||
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
|
||||
|
||||
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"opcua-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>Endpoint-URL-keyed host identity for the Admin /hosts dashboard.</summary>
|
||||
public string HostName => _options.EndpointUrl;
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -16,8 +16,16 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// <summary>Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>.</summary>
|
||||
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||
|
||||
/// <summary>Security policy. One of <c>None</c>, <c>Basic256Sha256</c>, <c>Aes128_Sha256_RsaOaep</c>.</summary>
|
||||
public string SecurityPolicy { get; init; } = "None";
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
/// forward-compatibility with future OPC UA policies not yet in the enum).
|
||||
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
|
||||
/// connects to the first endpoint whose policy name matches AND whose mode matches
|
||||
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
|
||||
/// the driver picks any unsecured endpoint regardless of policy string.
|
||||
/// </summary>
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
/// <summary>Security mode.</summary>
|
||||
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
||||
@@ -31,6 +39,23 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
|
||||
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
|
||||
/// loads the cert + private key, which the remote server validates against its
|
||||
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
|
||||
/// Leave unset to use the driver's application-instance certificate as the user
|
||||
/// token (not typical — most deployments have a separate user cert).
|
||||
/// </summary>
|
||||
public string? UserCertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
|
||||
/// protected. PEM files generally have their password on the adjacent key file; this
|
||||
/// knob only applies to password-locked PFX.
|
||||
/// </summary>
|
||||
public string? UserCertificatePassword { get; init; }
|
||||
|
||||
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
||||
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
@@ -96,6 +121,33 @@ public enum OpcUaSecurityMode
|
||||
SignAndEncrypt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA security policies recognized by the driver. Maps to the standard
|
||||
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
|
||||
/// endpoint matching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
|
||||
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
|
||||
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
|
||||
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
|
||||
/// </remarks>
|
||||
public enum OpcUaSecurityPolicy
|
||||
{
|
||||
/// <summary>No security. Unsigned, unencrypted wire.</summary>
|
||||
None,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic128Rsa15,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic256,
|
||||
/// <summary>Recommended baseline for current deployments.</summary>
|
||||
Basic256Sha256,
|
||||
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
|
||||
Aes128_Sha256_RsaOaep,
|
||||
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
|
||||
Aes256_Sha256_RsaPss,
|
||||
}
|
||||
|
||||
/// <summary>User authentication type sent to the remote server.</summary>
|
||||
public enum OpcUaAuthType
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_missing_path()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||
.Message.ShouldContain("UserCertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||
};
|
||||
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||
{
|
||||
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = tmpPath,
|
||||
UserCertificatePassword = "testpw",
|
||||
};
|
||||
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||
identity.ShouldNotBeNull();
|
||||
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed class OpcUaClientDriverScaffoldTests
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSecurityPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityPolicy.None)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
|
||||
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
|
||||
{
|
||||
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
|
||||
uri.ShouldNotBeNullOrEmpty();
|
||||
// Each URI should end in the enum name (for the non-None policies) so a driver
|
||||
// operator reading logs can correlate the URI back to the config value.
|
||||
if (policy != OpcUaSecurityPolicy.None)
|
||||
uri.ShouldContain(policy.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_None_matches_SDK_None_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
|
||||
.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
|
||||
.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
|
||||
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_enum_value_has_a_mapping()
|
||||
{
|
||||
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't
|
||||
/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
|
||||
/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
|
||||
/// fixture is scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeAndProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
|
||||
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
|
||||
// the caller's race with server-side cleanup after a session drop.
|
||||
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(
|
||||
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
|
||||
"opcua-hosts");
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
|
||||
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
|
||||
rows[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class FakeHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user