Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ await CoreClientUtils.SelectEndpointAsync(
}
catch (ServiceResultException e) when ((e.StatusCode is
StatusCodes.BadServerHalted or
StatusCodes.BadConnectionClosed or
StatusCodes.BadSecureChannelClosed or
StatusCodes.BadNoCommunication) &&
attempt < maxAttempts)
Expand Down Expand Up @@ -432,6 +433,7 @@ public async Task ConnectAsync(ConfiguredEndpoint endpoint, CancellationToken ct
catch (ServiceResultException e) when ((e.StatusCode is
StatusCodes.BadServerHalted or
StatusCodes.BadSecureChannelClosed or
StatusCodes.BadConnectionClosed or
StatusCodes.BadNoCommunication) &&
attempt < maxAttempts)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ await CoreClientUtils.SelectEndpointAsync(
catch (ServiceResultException e) when ((e.StatusCode is
StatusCodes.BadServerHalted or
StatusCodes.BadSecureChannelClosed or
StatusCodes.BadConnectionClosed or
StatusCodes.BadNoCommunication) &&
attempt < maxAttempts)
{
Expand Down Expand Up @@ -345,6 +346,7 @@ public async Task ConnectAsync(ConfiguredEndpoint endpoint, CancellationToken ct
catch (ServiceResultException e) when ((e.StatusCode is
StatusCodes.BadServerHalted or
StatusCodes.BadSecureChannelClosed or
StatusCodes.BadConnectionClosed or
StatusCodes.BadNoCommunication) &&
attempt < maxAttempts)
{
Expand Down
1 change: 1 addition & 0 deletions Libraries/Opc.Ua.Server/Server/StandardServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3340,6 +3340,7 @@ await subscriptionManager.StartupAsync(cancellationToken)
}

CertificateValidator.CertificateUpdate += OnCertificateUpdateAsync;
CertificateValidator.CertificateUpdateStarted += OnCertificateUpdateStartedAsync;
Comment on lines 3342 to +3343
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event handlers registered for CertificateUpdate and CertificateUpdateStarted are never unsubscribed. This can lead to memory leaks if the StandardServer is disposed while the CertificateValidator remains alive, as the validator will hold references to the server through these event handlers.

Consider unsubscribing from these events in the Dispose method or OnServerStoppingAsync to prevent memory leaks.

Copilot uses AI. Check for mistakes.
}

/// <summary>
Expand Down
10 changes: 10 additions & 0 deletions Stack/Opc.Ua.Bindings.Https/Stack/Https/HttpsTransportListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ public void Stop()
/// </summary>
public async Task SendAsync(HttpContext context)
{
// wait for certificate update to complete before processing requests.
m_quotas.CertificateValidator.CertificateUpdateInProgress.WaitOne();

string message = string.Empty;
CancellationToken ct = context.RequestAborted;
try
Expand Down Expand Up @@ -608,6 +611,13 @@ private bool ValidateClientCertificate(
return true;
}


/// <inheritdoc/>
public void CloseAllChannels(string reason)
{
// nothing to do
}

private EndpointDescriptionCollection m_descriptions;
private ChannelQuotas m_quotas;
private ITransportListenerCallback m_callback;
Expand Down
110 changes: 71 additions & 39 deletions Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public event CertificateUpdateEventHandler CertificateUpdate
remove => m_CertificateUpdate -= value;
}

/// <summary>
/// Raised before an application certificate update occurs.
/// </summary>
public event CertificateUpdateEventHandler CertificateUpdateStarted
{
add => m_CertificateUpdateStarted += value;
remove => m_CertificateUpdateStarted -= value;
}

/// <summary>
/// Updates the validator with the current state of the configuration.
/// </summary>
Expand Down Expand Up @@ -284,51 +293,69 @@ public virtual async Task UpdateCertificateAsync(
string applicationUri = null,
CancellationToken ct = default)
{
await m_semaphore.WaitAsync(ct).ConfigureAwait(false);
m_updateEvent.Reset();

try
{
m_applicationCertificates.Clear();
//
// crash occurs if the cert is in use still and this has not run yet.
// This might be the intended design but this runs on a free task that
// might not be scheduled right away.
//
// TODO: We need a better way to disconnect all sessions when the cert is
// updated. (See caller of this method)
//
// foreach (CertificateIdentifier applicationCertificate in securityConfiguration
// .ApplicationCertificates)
// {
// applicationCertificate.DisposeCertificate();
// }

foreach (CertificateIdentifier applicationCertificate in securityConfiguration
.ApplicationCertificates)
{
await applicationCertificate
.LoadPrivateKeyExAsync(
securityConfiguration.CertificatePasswordProvider,
applicationUri,
m_telemetry,
ct)
.ConfigureAwait(false);
CertificateUpdateEventHandler started_callback = m_CertificateUpdateStarted;
if (started_callback != null)
{
var args = new CertificateUpdateEventArgs(
securityConfiguration,
GetChannelValidator());
await started_callback(this, args).ConfigureAwait(false);
}

await m_semaphore.WaitAsync(ct).ConfigureAwait(false);

try
{
m_applicationCertificates.Clear();
//
// crash occurs if the cert is in use still and this has not run yet.
// This might be the intended design but this runs on a free task that
// might not be scheduled right away.
//
// TODO: We need a better way to disconnect all sessions when the cert is
// updated. (See caller of this method)
//
// foreach (CertificateIdentifier applicationCertificate in securityConfiguration
// .ApplicationCertificates)
// {
// applicationCertificate.DisposeCertificate();
// }

foreach (CertificateIdentifier applicationCertificate in securityConfiguration
.ApplicationCertificates)
{
await applicationCertificate
.LoadPrivateKeyExAsync(
securityConfiguration.CertificatePasswordProvider,
applicationUri,
m_telemetry,
ct)
.ConfigureAwait(false);
}
}
finally
{
m_semaphore.Release();
}
}
finally
{
m_semaphore.Release();
}

await UpdateAsync(securityConfiguration, applicationUri, ct).ConfigureAwait(false);
await UpdateAsync(securityConfiguration, applicationUri, ct).ConfigureAwait(false);

CertificateUpdateEventHandler callback = m_CertificateUpdate;
if (callback != null)
CertificateUpdateEventHandler callback = m_CertificateUpdate;
if (callback != null)
{
var args = new CertificateUpdateEventArgs(
securityConfiguration,
GetChannelValidator());
await callback(this, args).ConfigureAwait(false);
}
}
finally
{
var args = new CertificateUpdateEventArgs(
securityConfiguration,
GetChannelValidator());
callback(this, args);
m_updateEvent.Set();
}
}

Expand Down Expand Up @@ -2234,6 +2261,9 @@ public static bool IsECSecureForProfile(
throw new NotSupportedException("Unsupported curve type.");
}

/// <inheritdoc/>
public WaitHandle CertificateUpdateInProgress => m_updateEvent.WaitHandle;

/// <summary>
/// Flag to protect setting by application
/// from a modification by a SecurityConfiguration.
Expand All @@ -2254,13 +2284,15 @@ private enum ProtectFlags
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
private readonly Dictionary<string, X509Certificate2> m_validatedCertificates;
private readonly ManualResetEventSlim m_updateEvent = new(true);
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ManualResetEventSlim m_updateEvent is never disposed, which can lead to a resource leak. ManualResetEventSlim implements IDisposable and should be properly disposed when the CertificateValidator is disposed or finalized.

Consider making CertificateValidator implement IDisposable and dispose of m_updateEvent in the Dispose method, or dispose it in a finalizer if IDisposable implementation is not feasible.

Copilot uses AI. Check for mistakes.
private CertificateStoreIdentifier m_trustedCertificateStore;
private CertificateIdentifierCollection m_trustedCertificateList;
private CertificateStoreIdentifier m_issuerCertificateStore;
private CertificateIdentifierCollection m_issuerCertificateList;
private CertificateStoreIdentifier m_rejectedCertificateStore;
private event CertificateValidationEventHandler m_CertificateValidation;
private event CertificateUpdateEventHandler m_CertificateUpdate;
private event CertificateUpdateEventHandler m_CertificateUpdateStarted;
private readonly List<X509Certificate2> m_applicationCertificates;
private ProtectFlags m_protectFlags;
private bool m_autoAcceptUntrustedCertificates;
Expand Down Expand Up @@ -2350,7 +2382,7 @@ public CertificateUpdateEventArgs(
/// <summary>
/// Used to handle certificate update events.
/// </summary>
public delegate void CertificateUpdateEventHandler(
public delegate Task CertificateUpdateEventHandler(
CertificateValidator sender,
CertificateUpdateEventArgs e);
}
23 changes: 23 additions & 0 deletions Stack/Opc.Ua.Core/Security/Certificates/ICertificateValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ namespace Opc.Ua
/// </summary>
public interface ICertificateValidator
{
/// <summary>
/// Raised when an application certificate update occurs.
/// </summary>
event CertificateUpdateEventHandler CertificateUpdate;

/// <summary>
/// Raised before an application certificate update occurs.
/// </summary>
event CertificateUpdateEventHandler CertificateUpdateStarted;

/// <summary>
/// An event that signals that a certificate update is in progress.
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment states "An event that signals that a certificate update is in progress", but the actual behavior is the opposite. The event is signaled (set to true) when no update is in progress, and reset (set to false) during updates. The documentation should accurately describe that this WaitHandle is signaled when NO certificate update is in progress, and unsignaled during an update.

Suggested change
/// An event that signals that a certificate update is in progress.
/// A wait handle that is signaled when no certificate update is in progress
/// and unsignaled while a certificate update is in progress.

Copilot uses AI. Check for mistakes.
/// </summary>
WaitHandle CertificateUpdateInProgress { get; }
Comment on lines +52 to +54
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property name "CertificateUpdateInProgress" has inverted semantics. The WaitHandle is signaled (set) when the update is NOT in progress, and unsignaled (reset) when an update IS in progress. This is confusing because code like "CertificateUpdateInProgress.WaitOne()" actually waits for the update to complete, not to be in progress.

Consider renaming to "CertificateUpdateCompleted" or "CertificateUpdateNotInProgress" to better reflect the actual state being signaled. Alternatively, invert the logic by initializing the event as reset (false) and setting it during updates if you want to keep the current name.

Suggested change
/// An event that signals that a certificate update is in progress.
/// </summary>
WaitHandle CertificateUpdateInProgress { get; }
/// A wait handle that is signaled when no certificate update is in progress.
/// </summary>
WaitHandle CertificateUpdateCompleted { get; }

Copilot uses AI. Check for mistakes.

/// <summary>
/// Updates the validator with a new application certificate.
/// </summary>
Task UpdateCertificateAsync(
SecurityConfiguration securityConfiguration,
string applicationUri = null,
CancellationToken ct = default);

/// <summary>
/// Validates a certificate.
/// </summary>
Expand Down
21 changes: 20 additions & 1 deletion Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,7 @@ protected virtual EndpointBase GetEndpointInstance(ServerBase server)
/// <summary>
/// Called after the application certificate update.
/// </summary>
protected virtual async void OnCertificateUpdateAsync(object sender, CertificateUpdateEventArgs e)
protected virtual async Task OnCertificateUpdateAsync(object sender, CertificateUpdateEventArgs e)
{
try
{
Expand Down Expand Up @@ -810,6 +810,25 @@ await InstanceCertificateTypesProvider.LoadCertificateChainAsync(certificate)
}
}

/// <summary>
/// Called before the application certificate update.
/// </summary>
protected virtual Task OnCertificateUpdateStartedAsync(object sender, CertificateUpdateEventArgs e)
{
try
{
foreach (ITransportListener listener in TransportListeners)
{
listener.CloseAllChannels("Update of ApplicationCertificate");
}
}
catch (Exception ex)
{
m_logger.LogError(ex, "Failed to close all channels on certificate update: {EventArgs}", e);
}
return Task.CompletedTask;
}

/// <summary>
/// Create the transport listener for the service host endpoint.
/// </summary>
Expand Down
48 changes: 48 additions & 0 deletions Stack/Opc.Ua.Core/Stack/Tcp/TcpListenerChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,28 @@ public void IdleCleanup()
}
}

/// <summary>
/// Force the channel to close immediately, e.g. due to certificate update.
/// </summary>
public void ForceClose(string reason)
{
TcpChannelState state;

lock (DataLock)
{
state = State;
if (state is TcpChannelState.Open or TcpChannelState.Connecting)
{
state = State = TcpChannelState.Closing;
}
}

if (state is TcpChannelState.Closing or TcpChannelState.Opening or TcpChannelState.Faulted)
{
OnForceClosed(reason);
}
}

/// <summary>
/// The time in milliseconds elapsed since the channel received or sent messages
/// or received a keep alive.
Expand Down Expand Up @@ -349,6 +371,32 @@ private void OnCleanup(object state)
}
}

/// <summary>
/// Called when the channel is force closed.
/// </summary>
private void OnForceClosed(string reason)
{
lock (DataLock)
{
// nothing to do if the channel is now open or closed.
if (State is TcpChannelState.Closed or TcpChannelState.Open)
{
return;
}

m_logger.LogInformation(
"{Channel} Force Close Socket={SocketHandle:X8}, ChannelId={ChannelId}, TokenId={TokenId}, Reason={Reason}",
ChannelName,
(Socket?.Handle) ?? 0,
CurrentToken != null ? CurrentToken.ChannelId : 0,
CurrentToken != null ? CurrentToken.TokenId : 0,
reason);

// close channel.
ChannelClosed();
}
}

/// <summary>
/// Closes the channel and releases resources.
/// Sets state to Closed and notifies monitors.
Expand Down
12 changes: 12 additions & 0 deletions Stack/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,15 @@ public void Close()
Stop();
}

/// <inheritdoc/>
public void CloseAllChannels(string reason)
{
foreach (TcpListenerChannel channel in m_channels.Values.ToList())
{
channel.ForceClose(reason);
}
}

/// <inheritdoc/>
public void UpdateChannelLastActiveTime(string globalChannelId)
{
Expand Down Expand Up @@ -807,6 +816,9 @@ private void OnAccept(object sender, SocketAsyncEventArgs e)
{
bool isBlocked = false;

// wait for certificate update to complete
m_quotas.CertificateValidator.CertificateUpdateInProgress.WaitOne();

// Track potential problematic client behavior only if Basic128Rsa15 security policy is offered
if (m_activeClientTracker != null)
{
Expand Down
5 changes: 5 additions & 0 deletions Stack/Opc.Ua.Core/Stack/Transport/ITransportListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ void Open(
/// <exception cref="ServiceResultException">Thrown if any communication error occurs.</exception>
void Close();

/// <summary>
/// Closes all connections of the listener.
/// </summary>
void CloseAllChannels(string reason);

/// <summary>
/// Updates the application certificate for a listener.
/// </summary>
Expand Down
Loading
Loading