/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Opc.Ua.Server { /// /// Reverse connection states. /// public enum ReverseConnectState { /// /// The connection is closed. /// Closed = 0, /// /// The server is connecting. /// Connecting = 1, /// /// The server is connected with a client. /// Connected = 2, /// /// The client rejected the connection with the server. /// Rejected = 3, /// /// An error occurred connecting with the client. /// Errored = 4 } /// /// Describes the properties of a server reverse connection. /// public class ReverseConnectProperty { /// /// Initialize a reverse connect server property. /// /// The Url of the reverse connect client. /// The timeout to use for a reverse connect attempt. /// The maximum number of sessions allowed to the client. /// If this is an application configuration entry. /// If the connection is enabled. public ReverseConnectProperty( Uri clientUrl, int timeout, int maxSessionCount, bool configEntry, bool enabled = true) { ClientUrl = clientUrl; Timeout = timeout > 0 ? timeout : ReverseConnectServer.DefaultReverseConnectTimeout; MaxSessionCount = maxSessionCount; ConfigEntry = configEntry; Enabled = enabled; } /// /// The Url of the reverse connect client. /// public readonly Uri ClientUrl; /// /// The timeout to use for a reverse connect attempt. /// public readonly int Timeout; /// /// If this is an application configuration entry. /// public readonly bool ConfigEntry; /// /// The service result of the last connection attempt. /// public ServiceResult? ServiceResult; /// /// The last state of the reverse connection. /// public ReverseConnectState LastState = ReverseConnectState.Closed; /// /// The maximum number of sessions allowed to the client. /// public int MaxSessionCount; /// /// If the connection is enabled. /// public bool Enabled; /// /// The time when the connection was rejected. /// public DateTime RejectTime; } /// /// The standard implementation of a UA server with reverse connect. /// public class ReverseConnectServer : StandardServer { /// /// The default reverse connect interval. /// public static int DefaultReverseConnectInterval => 15000; /// /// The default reverse connect timeout. /// public static int DefaultReverseConnectTimeout => 30000; /// /// The default timeout after a rejected connection attempt. /// public static int DefaultReverseConnectRejectTimeout => 60000; /// /// Creates a reverse connect server based on a StandardServer. /// public ReverseConnectServer(ITelemetryContext telemetry) : this(telemetry, null) { } /// /// Creates a reverse connect server with an explicit /// so the reverse-connect timer and /// expiry logic can be mocked in tests. /// /// The telemetry context. /// The time provider used for all /// time / duration calculations, or null to use /// . public ReverseConnectServer(ITelemetryContext telemetry, TimeProvider? timeProvider) : base(telemetry, timeProvider) { m_connectInterval = DefaultReverseConnectInterval; m_connectTimeout = DefaultReverseConnectTimeout; m_rejectTimeout = DefaultReverseConnectRejectTimeout; m_connections = []; } /// protected override void OnServerStarted(IServerInternal server) { base.OnServerStarted(server); UpdateConfiguration(Configuration!); StartTimer(true); } /// protected override async ValueTask OnUpdateConfigurationAsync(ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { await base.OnUpdateConfigurationAsync(configuration, cancellationToken) .ConfigureAwait(false); UpdateConfiguration(configuration); } /// protected override ValueTask OnServerStoppingAsync(CancellationToken cancellationToken = default) { DisposeTimer(); return base.OnServerStoppingAsync(cancellationToken); } /// protected override void Dispose(bool disposing) { if (disposing) { m_reverseConnectTimer?.Dispose(); m_reverseConnectTimer = null; } base.Dispose(disposing); } /// /// Add a reverse connection url. /// /// public virtual void AddReverseConnection( Uri url, int timeout = 0, int maxSessionCount = 0, bool enabled = true) { if (m_connections.ContainsKey(url)) { throw new ArgumentException( "Connection for specified clientUrl is already configured", nameof(url)); } var reverseConnection = new ReverseConnectProperty( url, timeout, maxSessionCount, false, enabled); lock (m_connectionsLock) { m_connections[url] = reverseConnection; m_logger.LogInformation("Reverse Connection added for EndpointUrl: {Url}.", url); StartTimer(false); } } /// /// Remove a reverse connection url. /// /// true if the reverse connection is found and removed /// is null. public virtual bool RemoveReverseConnection(Uri url) { if (url == null) { throw new ArgumentNullException(nameof(url)); } lock (m_connectionsLock) { bool connectionRemoved = m_connections.Remove(url); if (connectionRemoved) { m_logger.LogInformation("Reverse Connection removed for EndpointUrl: {Url}.", url); } if (m_connections.Count == 0) { DisposeTimer(); } return connectionRemoved; } } /// /// Return a dictionary of configured reverse connection Urls. /// public virtual ReadOnlyDictionary GetReverseConnections() { lock (m_connectionsLock) { return new ReadOnlyDictionary(m_connections); } } /// /// Timer callback to establish new reverse connections. /// private void OnReverseConnect(object? state) { try { lock (m_connectionsLock) { foreach (ReverseConnectProperty reverseConnection in m_connections.Values) { // recharge a rejected connection after timeout if (reverseConnection.LastState == ReverseConnectState.Rejected && reverseConnection.RejectTime + TimeSpan.FromMilliseconds(m_rejectTimeout) < TimeProvider.GetUtcNow().UtcDateTime) { reverseConnection.LastState = ReverseConnectState.Closed; } // try the reverse connect if (reverseConnection.Enabled && ( reverseConnection.MaxSessionCount == 0 || ( reverseConnection.MaxSessionCount == 1 && reverseConnection.LastState == ReverseConnectState.Closed ) || reverseConnection.MaxSessionCount > ServerInternal.SessionManager .GetSessions() .Count)) { try { reverseConnection.LastState = ReverseConnectState.Connecting; CreateConnection( reverseConnection.ClientUrl, reverseConnection.Timeout > 0 ? reverseConnection.Timeout : m_connectTimeout); m_logger.LogInformation( "Create Connection! [{LastState}][{ClientUrl}]", reverseConnection.LastState, reverseConnection.ClientUrl); } catch (Exception e) { reverseConnection.LastState = ReverseConnectState.Errored; reverseConnection.ServiceResult = new ServiceResult(e); m_logger.LogError( "Create Connection failed! [{LastState}][{ClientUrl}]", reverseConnection.LastState, reverseConnection.ClientUrl); } } } } } catch (Exception ex) { m_logger.LogError(ex, "OnReverseConnect unexpected error"); } finally { StartTimer(true); } } /// /// Track reverse connection status. /// protected override void OnConnectionStatusChanged( object? sender, ConnectionStatusEventArgs e) { lock (m_connectionsLock) { if (m_connections.TryGetValue( e.EndpointUrl, out ReverseConnectProperty? reverseConnection)) { if (ServiceResult.IsBad(e.ChannelStatus)) { reverseConnection.ServiceResult = e.ChannelStatus; if (e.ChannelStatus.Code == StatusCodes.BadTcpMessageTypeInvalid) { reverseConnection.LastState = ReverseConnectState.Rejected; reverseConnection.RejectTime = TimeProvider.GetUtcNow().UtcDateTime; m_logger.LogWarning( "Client Rejected Connection! [{LastState}][{EndpointUrl}]", reverseConnection.LastState, e.EndpointUrl); return; } reverseConnection.LastState = ReverseConnectState.Closed; m_logger.LogError( "Connection Error! [{LastState}][{EndpointUrl}]", reverseConnection.LastState, e.EndpointUrl); return; } reverseConnection.LastState = e.Closed ? ReverseConnectState.Closed : ReverseConnectState.Connected; m_logger.LogInformation( "New Connection State! [{LastState}][{EndpointUrl}]", reverseConnection.LastState, e.EndpointUrl); } else { m_logger.LogWarning( "Warning: Status changed for unknown reverse connection: [{ChannelStatus}][{EndpointUrl}]", e.ChannelStatus, e.EndpointUrl); } } base.OnConnectionStatusChanged(sender, e); } /// /// Restart the timer. /// private void StartTimer(bool forceRestart) { if (forceRestart) { DisposeTimer(); } lock (m_connectionsLock) { if (m_connectInterval > 0 && m_connections.Count > 0 && m_reverseConnectTimer == null) { m_reverseConnectTimer = TimeProvider.CreateTimer( OnReverseConnect, this, TimeSpan.FromMilliseconds(forceRestart ? m_connectInterval : 1000), Timeout.InfiniteTimeSpan); } } } /// /// Dispose the current timer. /// private void DisposeTimer() { // start registration timer. lock (m_connectionsLock) { m_reverseConnectTimer?.Dispose(); m_reverseConnectTimer = null; } } /// /// Remove a reverse connection url. /// private void ClearConnections(bool configEntry) { lock (m_connectionsLock) { foreach ( KeyValuePair entry in m_connections.Where(r => r.Value.ConfigEntry == configEntry)) { m_connections.Remove(entry.Key); } } } /// /// Update the reverse connect configuration from the application configuration. /// private void UpdateConfiguration(ApplicationConfiguration configuration) { ClearConnections(true); // get the configuration for the reverse connections. ReverseConnectServerConfiguration? reverseConnect = configuration?.ServerConfiguration? .ReverseConnect; // add configuration reverse client connection properties. if (reverseConnect != null) { lock (m_connectionsLock) { m_connectInterval = reverseConnect.ConnectInterval > 0 ? reverseConnect.ConnectInterval : DefaultReverseConnectInterval; m_connectTimeout = reverseConnect.ConnectTimeout > 0 ? reverseConnect.ConnectTimeout : DefaultReverseConnectTimeout; m_rejectTimeout = reverseConnect.RejectTimeout > 0 ? reverseConnect.RejectTimeout : DefaultReverseConnectRejectTimeout; if (!reverseConnect.Clients.IsEmpty) { foreach (ReverseConnectClient client in reverseConnect.Clients) { Uri? uri = Utils.ParseUri(client.EndpointUrl); if (uri != null) { if (m_connections.ContainsKey(uri)) { m_logger.LogWarning( "Warning: ServerConfiguration.ReverseConnect contains duplicate EndpointUrl: {Uri}.", uri); } else { m_connections[uri] = new ReverseConnectProperty( uri, client.Timeout, client.MaxSessionCount, true, client.Enabled); m_logger.LogInformation( "Reverse Connection added for EndpointUrl: {Uri}.", uri); } } } } } } } private ITimer? m_reverseConnectTimer; private int m_connectInterval; private int m_connectTimeout; private int m_rejectTimeout; private readonly Dictionary m_connections; private readonly Lock m_connectionsLock = new(); } }