fix(dcl): OPC UA secure client presents a real application certificate (#121)
Sign/SignAndEncrypt sessions failed before activation with "ApplicationCertificate
for the security profile ... cannot be found" (BadConfigurationError): both
RealOpcUaClient.ConnectAsync and the verify probe built an empty CertificateIdentifier.
- New OpcUaApplicationIdentity builds the client ApplicationConfiguration for both
paths. Secured modes load the application certificate from a configurable own
store (subject + ApplicationUri), auto-creating a self-signed RSA-2048 cert only
when none exists. A persisted cert is reused across reconnect/restart and never
silently rotated: one that no longer validates (e.g. ApplicationUri mismatch)
fails closed. First-time creation is serialised per own store and logged at
Warning with the thumbprint. None mode is unchanged and never touches the store.
- New OpcUaGlobalOptions: ApplicationUri, ApplicationCertificateSubject,
OwnCertificateStorePath, AutoCreateApplicationCertificate,
ApplicationCertificateLifetimeMonths (validated).
- Owned resources: the private-key cert is disposed on disconnect, failed connect
and at probe end; sessions are now disposed after close, and a reconnect on the
same instance closes the previous session first.
- CertStoreActor wrote trusted server certs to the store ROOT, which the SDK never
reads (it uses {store}/certs), so an operator Trust never affected validation and
SDK-rejected certs never listed. It now uses certs/ and migrates legacy root files
on start.
- Rig: all site nodes (docker/, docker-env2/) put the four PKI stores on the
persistent /app/data volume with a pinned ApplicationUri.
- Tests: in-process strict-trust StandardServer (test-only Opc.Ua.Server 1.5.378.106)
exercising the real adapter through ConnectAsync and VerifyEndpointAsync for Sign
and SignAndEncrypt: allow + read + reconnect + restart on one identity, deny when
the client doesn't trust the server (BadCertificateUntrusted), deny when the server
doesn't trust the client (BadSecurityChecksFailed), None untouched, concurrent
provisioning creates one cert, URI mismatch fails without rotating, auto-create off.
Against the pre-fix client these tests fail with the exact #121 error.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OPC UA client <see cref="ApplicationConfiguration"/> and owns the application
|
||||
/// (client) certificate that Sign / SignAndEncrypt sessions present to the server (Gitea #121).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #121 both <see cref="RealOpcUaClient.ConnectAsync"/> and the verify probe built an
|
||||
/// EMPTY <see cref="CertificateIdentifier"/>, so every secured session failed before activation
|
||||
/// with <c>ApplicationCertificate for the security profile … cannot be found</c>
|
||||
/// (<c>BadConfigurationError</c>). The identity now lives in a configurable own store
|
||||
/// (<see cref="OpcUaGlobalOptions.OwnCertificateStorePath"/>), located by subject, carrying the
|
||||
/// configured ApplicationUri.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reuse, never silent rotation.</b> An existing certificate in the own store is loaded and
|
||||
/// reused. One that no longer validates (ApplicationUri mismatch, undersized key, missing private
|
||||
/// key) FAILS the connect with the SDK's "update or delete the certificate" error — the SDK runs
|
||||
/// silent, so it never regenerates over a persisted identity a server has already trusted. A new
|
||||
/// certificate is only created when none exists and
|
||||
/// <see cref="OpcUaGlobalOptions.AutoCreateApplicationCertificate"/> is on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>None stays untouched.</b> Unsecured sessions keep the empty identifier and never read or
|
||||
/// write the own store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class OpcUaApplicationIdentity
|
||||
{
|
||||
/// <summary>Default application name when the configured one is blank.</summary>
|
||||
internal const string DefaultApplicationName = "ScadaBridge-DCL";
|
||||
|
||||
/// <summary>Minimum RSA key size accepted for (and used when creating) the application certificate.</summary>
|
||||
internal const ushort MinimumKeySize = 2048;
|
||||
|
||||
// SDK telemetry for the non-obsolete ApplicationInstance ctor. SDK-internal logging is
|
||||
// discarded; this class logs the outcome that matters (created vs reused) itself.
|
||||
private static readonly Lazy<ITelemetryContext> SdkTelemetry = new(() => DefaultTelemetry.Create(_ => { }));
|
||||
|
||||
// One gate per own-store directory: two connections starting together against an empty
|
||||
// store would otherwise BOTH generate a certificate, leaving two identities for one subject.
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> ProvisionGates =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a configured PKI store path; empty falls back to
|
||||
/// <c>{temp}/ScadaBridge/pki/{fallbackLeaf}</c>. The single definition shared by the client,
|
||||
/// the verify probe and the site's <c>CertStoreActor</c>.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured path (may be blank).</param>
|
||||
/// <param name="fallbackLeaf">Leaf directory used under the temp root when blank.</param>
|
||||
/// <returns>The store directory.</returns>
|
||||
public static string ResolveStorePath(string configured, string fallbackLeaf) =>
|
||||
string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
/// <summary>
|
||||
/// Directory inside a store where the SDK's directory store keeps certificates
|
||||
/// (<c>{store}/certs</c>). Trusting a peer means writing its DER here — a DER in the store
|
||||
/// ROOT is invisible to certificate validation.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The store root.</param>
|
||||
/// <returns>The certificate sub-directory.</returns>
|
||||
public static string CertificatesDirectory(string storePath) => Path.Combine(storePath, "certs");
|
||||
|
||||
/// <summary>The effective application name.</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The configured name, or the default when blank.</returns>
|
||||
public static string ResolveApplicationName(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationName) ? DefaultApplicationName : options.ApplicationName;
|
||||
|
||||
/// <summary>The effective ApplicationUri (configured, else <c>urn:{host}:{ApplicationName}</c>).</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The ApplicationUri.</returns>
|
||||
public static string ResolveApplicationUri(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationUri)
|
||||
? $"urn:{Dns.GetHostName()}:{ResolveApplicationName(options)}"
|
||||
: options.ApplicationUri;
|
||||
|
||||
/// <summary>The effective certificate subject (configured, else <c>CN={ApplicationName}</c>).</summary>
|
||||
/// <param name="options">Global OPC UA options.</param>
|
||||
/// <returns>The subject distinguished name.</returns>
|
||||
public static string ResolveSubject(OpcUaGlobalOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ApplicationCertificateSubject)
|
||||
? $"CN={ResolveApplicationName(options)}"
|
||||
: options.ApplicationCertificateSubject;
|
||||
|
||||
/// <summary>
|
||||
/// Builds and validates a client <see cref="ApplicationConfiguration"/>. When
|
||||
/// <paramref name="secured"/> is true the application certificate is loaded from — or, if
|
||||
/// absent and allowed, created in — the own store before returning; otherwise the identifier
|
||||
/// is left empty exactly as before #121.
|
||||
/// </summary>
|
||||
/// <param name="options">Global OPC UA identity and store paths.</param>
|
||||
/// <param name="secured">True for Sign / SignAndEncrypt; false for None.</param>
|
||||
/// <param name="autoAcceptUntrusted">The connection's AutoAccept flag (the probe always passes false).</param>
|
||||
/// <param name="sessionTimeoutMs">Default session timeout.</param>
|
||||
/// <param name="operationTimeoutMs">Transport operation timeout.</param>
|
||||
/// <param name="logger">Logger for the created/reused outcome.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The validated configuration. The caller owns it — see <see cref="ReleaseApplicationCertificate"/>.</returns>
|
||||
internal static async Task<ApplicationConfiguration> BuildClientConfigurationAsync(
|
||||
OpcUaGlobalOptions options, bool secured, bool autoAcceptUntrusted,
|
||||
int sessionTimeoutMs, int operationTimeoutMs, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = ResolveApplicationName(options),
|
||||
ApplicationUri = ResolveApplicationUri(options),
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
AutoAcceptUntrustedCertificates = autoAcceptUntrusted,
|
||||
// Our own identity belongs in the own store, not in the operator-managed trusted
|
||||
// peer store that the Central UI lists — the SDK default (true) would copy it there.
|
||||
AddAppCertToTrustedStore = false,
|
||||
ApplicationCertificate = secured ? NewOwnIdentifier(options) : new CertificateIdentifier(),
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(options.TrustedIssuerStorePath, "issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(options.TrustedPeerStorePath, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(options.RejectedCertificateStorePath, "rejected") },
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = sessionTimeoutMs },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = operationTimeoutMs },
|
||||
};
|
||||
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
|
||||
if (secured)
|
||||
await EnsureLoadedAsync(appConfig, options, logger, ct).ConfigureAwait(false);
|
||||
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the application certificate exists in the own store (creating it when absent and
|
||||
/// allowed) and returns a PUBLIC-ONLY copy — the DER an operator hands to a server's trust
|
||||
/// list. Uses the same reuse/creation path as a secured connect, so calling this never
|
||||
/// rotates an identity a connect would have reused.
|
||||
/// </summary>
|
||||
/// <param name="options">Global OPC UA identity and store paths.</param>
|
||||
/// <param name="logger">Logger for the created/reused outcome.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The application certificate without its private key; the caller disposes it.</returns>
|
||||
public static async Task<X509Certificate2> EnsureApplicationCertificateAsync(
|
||||
OpcUaGlobalOptions options, ILogger logger, CancellationToken ct = default)
|
||||
{
|
||||
var appConfig = await BuildClientConfigurationAsync(
|
||||
options, secured: true, autoAcceptUntrusted: false, sessionTimeoutMs: 60_000,
|
||||
operationTimeoutMs: 15_000, logger, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var cert = appConfig.SecurityConfiguration.ApplicationCertificate.Certificate
|
||||
?? throw new InvalidOperationException("OPC UA application certificate was not loaded.");
|
||||
return X509CertificateLoader.LoadCertificate(cert.RawData);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseApplicationCertificate(appConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the private-key-bearing application certificate a configuration loaded. Call
|
||||
/// only once every session built from <paramref name="appConfig"/> is closed and disposed.
|
||||
/// </summary>
|
||||
/// <param name="appConfig">A configuration from <see cref="BuildClientConfigurationAsync"/>; null is a no-op.</param>
|
||||
internal static void ReleaseApplicationCertificate(ApplicationConfiguration? appConfig)
|
||||
{
|
||||
var certificates = appConfig?.SecurityConfiguration?.ApplicationCertificates;
|
||||
if (certificates is null)
|
||||
return;
|
||||
foreach (var id in certificates)
|
||||
{
|
||||
var cert = id?.Certificate;
|
||||
if (cert is null)
|
||||
continue;
|
||||
id!.Certificate = null;
|
||||
cert.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static CertificateIdentifier NewOwnIdentifier(OpcUaGlobalOptions options) => new()
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = ResolveStorePath(options.OwnCertificateStorePath, "own"),
|
||||
SubjectName = ResolveSubject(options),
|
||||
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType,
|
||||
};
|
||||
|
||||
private static async Task EnsureLoadedAsync(
|
||||
ApplicationConfiguration appConfig, OpcUaGlobalOptions options, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var id = appConfig.SecurityConfiguration.ApplicationCertificate;
|
||||
var gate = ProvisionGates.GetOrAdd(Path.GetFullPath(id.StorePath), _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var before = ExistingThumbprints(id.StorePath);
|
||||
|
||||
var app = new ApplicationInstance(appConfig, SdkTelemetry.Value)
|
||||
{
|
||||
ApplicationName = appConfig.ApplicationName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
DisableCertificateAutoCreation = !options.AutoCreateApplicationCertificate,
|
||||
};
|
||||
|
||||
bool ok;
|
||||
try
|
||||
{
|
||||
ok = await app.CheckApplicationInstanceCertificatesAsync(
|
||||
silent: true, (ushort)options.ApplicationCertificateLifetimeMonths, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ServiceResultException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate '{id.SubjectName}' in '{id.StorePath}' is unusable for " +
|
||||
$"ApplicationUri '{appConfig.ApplicationUri}': {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var cert = id.Certificate;
|
||||
if (!ok || cert is null || !cert.HasPrivateKey)
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate '{id.SubjectName}' could not be loaded with its private key from '{id.StorePath}'.");
|
||||
|
||||
var created = !before.Contains(cert.Thumbprint);
|
||||
logger.Log(created ? LogLevel.Warning : LogLevel.Debug,
|
||||
"OPC UA application certificate {Outcome}: subject {Subject}, thumbprint {Thumbprint}, " +
|
||||
"ApplicationUri {ApplicationUri}, store {StorePath}, expires {NotAfter:O}.{Hint}",
|
||||
created ? "CREATED" : "reused", cert.Subject, cert.Thumbprint, appConfig.ApplicationUri,
|
||||
id.StorePath, cert.NotAfter.ToUniversalTime(),
|
||||
created ? " Every server this node connects to with Sign/SignAndEncrypt must trust this certificate." : "");
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<string> ExistingThumbprints(string storePath)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var dir = CertificatesDirectory(storePath);
|
||||
if (!Directory.Exists(dir))
|
||||
return set;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.der"))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var c = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file));
|
||||
set.Add(c.Thumbprint);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// An unreadable file is not an identity we could have reused.
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
// 0 = not fired, 1 = fired.
|
||||
private int _connectionLostFired;
|
||||
private OpcUaConnectionOptions _options = new();
|
||||
// The configuration of the current/last connect; owns the loaded application certificate
|
||||
// (private key), released once the session built from it is closed.
|
||||
private ApplicationConfiguration? _appConfig;
|
||||
private readonly OpcUaGlobalOptions _globalOptions;
|
||||
private readonly ILogger<RealOpcUaClient> _logger;
|
||||
|
||||
@@ -162,25 +165,16 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
_ => MessageSecurityMode.None
|
||||
};
|
||||
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
|
||||
? "ScadaBridge-DCL"
|
||||
: _globalOptions.ApplicationName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
|
||||
};
|
||||
// A reconnect on the same instance must not leak the previous session's identity.
|
||||
await ReleaseSessionAsync(cancellationToken);
|
||||
|
||||
await appConfig.ValidateAsync(ApplicationType.Client);
|
||||
// Gitea #121: secured modes load (or first-time create) the application certificate
|
||||
// from the own store; None keeps the empty identifier and never touches the own store.
|
||||
var appConfig = await OpcUaApplicationIdentity.BuildClientConfigurationAsync(
|
||||
_globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None,
|
||||
opts.AutoAcceptUntrustedCerts, opts.SessionTimeoutMs, opts.OperationTimeoutMs,
|
||||
_logger, cancellationToken);
|
||||
_appConfig = appConfig;
|
||||
if (opts.AutoAcceptUntrustedCerts)
|
||||
{
|
||||
// This accepts ANY server certificate, defeating
|
||||
@@ -210,9 +204,18 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
#pragma warning restore CS0618
|
||||
var userIdentity = BuildUserIdentity(opts.UserIdentity);
|
||||
_writeDataTypes.Clear();
|
||||
_session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
|
||||
try
|
||||
{
|
||||
_session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig);
|
||||
_appConfig = null;
|
||||
throw;
|
||||
}
|
||||
|
||||
// Detect server going offline via keep-alive failures
|
||||
Interlocked.Exchange(ref _connectionLostFired, 0);
|
||||
@@ -372,30 +375,11 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
_ => MessageSecurityMode.None
|
||||
};
|
||||
|
||||
// Secure-by-default — force AutoAccept=false so an untrusted server cert is
|
||||
// captured and rejected rather than silently accepted (defeating the whole probe).
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = string.IsNullOrWhiteSpace(globalOptions.ApplicationName)
|
||||
? "ScadaBridge-DCL"
|
||||
: globalOptions.ApplicationName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
AutoAcceptUntrustedCertificates = false,
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedIssuerStorePath, "issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedPeerStorePath, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.RejectedCertificateStorePath, "rejected") }
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = config.SessionTimeoutMs },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = config.OperationTimeoutMs }
|
||||
};
|
||||
|
||||
ApplicationConfiguration? appConfig = null;
|
||||
// Capture the untrusted server cert, then REJECT it (e.Accept = false). The
|
||||
// validator runs on the SDK's connect thread; copying the cert is the only state we
|
||||
// keep. Never accept — this probe must not trust anything.
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) =>
|
||||
CertificateValidationEventHandler captureAndReject = (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -415,7 +399,15 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
|
||||
try
|
||||
{
|
||||
await appConfig.ValidateAsync(ApplicationType.Client);
|
||||
// Secure-by-default — force AutoAccept=false so an untrusted server cert is captured
|
||||
// and rejected rather than silently accepted (defeating the whole probe). A secured
|
||||
// probe presents the SAME application certificate a real connect would (Gitea #121),
|
||||
// so a probe success means the real connect's mutual trust holds too.
|
||||
appConfig = await OpcUaApplicationIdentity.BuildClientConfigurationAsync(
|
||||
globalOptions, secured: preferredSecurityMode != MessageSecurityMode.None,
|
||||
autoAcceptUntrusted: false, config.SessionTimeoutMs, config.OperationTimeoutMs,
|
||||
logger, linkedCts.Token);
|
||||
appConfig.CertificateValidator.CertificateValidation += captureAndReject;
|
||||
|
||||
// Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
|
||||
// Bounded by the probe's own budget (linkedCts), not just the SDK operation timeout.
|
||||
@@ -460,6 +452,9 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
|
||||
session.Dispose();
|
||||
}
|
||||
if (appConfig != null)
|
||||
appConfig.CertificateValidator.CertificateValidation -= captureAndReject;
|
||||
OpcUaApplicationIdentity.ReleaseApplicationCertificate(appConfig);
|
||||
}
|
||||
|
||||
return MapVerifyOutcome(failure, capturedCert);
|
||||
@@ -565,12 +560,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (_session != null)
|
||||
{
|
||||
_session.KeepAlive -= OnSessionKeepAlive;
|
||||
await _session.CloseAsync(cancellationToken);
|
||||
_session = null;
|
||||
}
|
||||
await ReleaseSessionAsync(cancellationToken);
|
||||
_writeDataTypes.Clear();
|
||||
_monitoredItems.Clear();
|
||||
_callbacks.Clear();
|
||||
@@ -580,6 +570,36 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
_alarmLastState.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes and disposes the current session (if any), then releases the application
|
||||
/// certificate its configuration loaded. Close failures are swallowed into a debug log so the
|
||||
/// owned resources are always released.
|
||||
/// </summary>
|
||||
private async Task ReleaseSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var session = _session;
|
||||
_session = null;
|
||||
if (session != null)
|
||||
{
|
||||
session.KeepAlive -= OnSessionKeepAlive;
|
||||
try
|
||||
{
|
||||
await session.CloseAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// A dead link cannot close cleanly; that must not block a reconnect.
|
||||
_logger.LogDebug(ex, "OPC UA session close failed (ignored).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
OpcUaApplicationIdentity.ReleaseApplicationCertificate(_appConfig);
|
||||
_appConfig = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> CreateSubscriptionAsync(
|
||||
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
|
||||
@@ -1593,11 +1613,6 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
_ => TimestampsToReturn.Source
|
||||
};
|
||||
|
||||
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
|
||||
string.IsNullOrWhiteSpace(configured)
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
|
||||
@@ -4,12 +4,46 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
/// Deployment-wide OPC UA application identity. Bound from the "ScadaBridge:OpcUa"
|
||||
/// section of appsettings.json. Per-endpoint behavior lives on OpcUaEndpointConfig.
|
||||
/// Empty paths fall back to a default under Path.GetTempPath() so dev runs
|
||||
/// work without explicit configuration.
|
||||
/// work without explicit configuration — production nodes must point every store at a
|
||||
/// persistent volume, or the application certificate is regenerated (and must be re-trusted
|
||||
/// by every server) whenever the temp directory is cleared.
|
||||
/// </summary>
|
||||
public class OpcUaGlobalOptions
|
||||
{
|
||||
/// <summary>OPC UA application name used in the application certificate and session negotiation.</summary>
|
||||
public string ApplicationName { get; set; } = "ScadaBridge-DCL";
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA ApplicationUri presented in the session and embedded (as the SAN URI) in the
|
||||
/// application certificate. Empty = <c>urn:{host name}:{ApplicationName}</c>. A persisted
|
||||
/// certificate whose SAN URI no longer matches is REJECTED at connect (it is never silently
|
||||
/// replaced), so pin this explicitly on hosts whose name can change.
|
||||
/// </summary>
|
||||
public string ApplicationUri { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Subject distinguished name of the application certificate. Empty = <c>CN={ApplicationName}</c>.
|
||||
/// The certificate is located in <see cref="OwnCertificateStorePath"/> by this subject.
|
||||
/// </summary>
|
||||
public string ApplicationCertificateSubject { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// File system path to the application's OWN certificate store (certificate + private key),
|
||||
/// used for Sign / SignAndEncrypt sessions. Empty = a default under Path.GetTempPath().
|
||||
/// </summary>
|
||||
public string OwnCertificateStorePath { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) a self-signed application certificate is generated in
|
||||
/// <see cref="OwnCertificateStorePath"/> the first time a secured session needs one and none
|
||||
/// exists. When false a missing certificate fails the connect — for deployments that
|
||||
/// provision the certificate from their own PKI.
|
||||
/// </summary>
|
||||
public bool AutoCreateApplicationCertificate { get; set; } = true;
|
||||
|
||||
/// <summary>Lifetime, in months, of an auto-created application certificate (1–600).</summary>
|
||||
public int ApplicationCertificateLifetimeMonths { get; set; } = 24;
|
||||
|
||||
/// <summary>File system path to the trusted issuer certificate store.</summary>
|
||||
public string TrustedIssuerStorePath { get; set; } = "";
|
||||
/// <summary>File system path to the trusted peer certificate store.</summary>
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||
/// <summary>Validates the OPC UA application identity before connections start.</summary>
|
||||
public sealed class OpcUaGlobalOptionsValidator : IValidateOptions<OpcUaGlobalOptions>
|
||||
{
|
||||
/// <summary>Fails validation when the OPC UA application name is blank.</summary>
|
||||
/// <summary>
|
||||
/// Fails validation when the OPC UA application name is blank, the ApplicationUri is not an
|
||||
/// absolute URI, or the certificate lifetime is out of range.
|
||||
/// </summary>
|
||||
/// <param name="name">The named options instance being validated.</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
@@ -13,6 +16,13 @@ public sealed class OpcUaGlobalOptionsValidator : IValidateOptions<OpcUaGlobalOp
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ApplicationName))
|
||||
return ValidateOptionsResult.Fail("ScadaBridge:OpcUa:ApplicationName must not be blank.");
|
||||
if (!string.IsNullOrWhiteSpace(options.ApplicationUri)
|
||||
&& !Uri.TryCreate(options.ApplicationUri, UriKind.Absolute, out _))
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"ScadaBridge:OpcUa:ApplicationUri '{options.ApplicationUri}' must be an absolute URI (e.g. urn:host:ScadaBridge-DCL).");
|
||||
if (options.ApplicationCertificateLifetimeMonths is < 1 or > 600)
|
||||
return ValidateOptionsResult.Fail(
|
||||
"ScadaBridge:OpcUa:ApplicationCertificateLifetimeMonths must be between 1 and 600.");
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user