using System.Collections.Concurrent; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; namespace ScadaLink.DataConnectionLayer.Adapters; /// /// Real OPC UA client implementation using the OPC Foundation .NET Standard Library. /// Wraps Session, Subscription, and MonitoredItem for tag subscriptions. /// public class RealOpcUaClient : IOpcUaClient { private ISession? _session; private Subscription? _subscription; // DataConnectionLayer-003: these maps are read from the OPC Foundation SDK's // internal publish threads (the MonitoredItem.Notification handler reads // _callbacks) concurrently with subscribe/disconnect mutations that run on // thread-pool threads. Plain Dictionary access during a concurrent resize or // Clear() is undefined behaviour, so they must be ConcurrentDictionary. private readonly ConcurrentDictionary _monitoredItems = new(); private readonly ConcurrentDictionary> _callbacks = new(); // DataConnectionLayer-013: int flag toggled with Interlocked.Exchange so the // once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible. // 0 = not fired, 1 = fired. private int _connectionLostFired; private OpcUaConnectionOptions _options = new(); private readonly OpcUaGlobalOptions _globalOptions; private readonly ILogger _logger; public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null, ILogger? logger = null) { _globalOptions = globalOptions ?? new OpcUaGlobalOptions(); _logger = logger ?? NullLogger.Instance; } public bool IsConnected => _session?.Connected ?? false; public event Action? ConnectionLost; public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default) { var opts = options ?? new OpcUaConnectionOptions(); var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch { "SIGN" => MessageSecurityMode.Sign, "SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt, _ => MessageSecurityMode.None }; var appConfig = new ApplicationConfiguration { ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName) ? "ScadaLink-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 } }; await appConfig.ValidateAsync(ApplicationType.Client); if (opts.AutoAcceptUntrustedCerts) { // DataConnectionLayer-012: this accepts ANY server certificate, defeating // certificate trust enforcement. Surface a prominent warning so an operator // who has opted in is aware of the man-in-the-middle exposure on the link. _logger.LogWarning( "OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " + "server certificate is accepted unconditionally. This defeats Sign / " + "SignAndEncrypt protection against a man-in-the-middle.", endpointUrl); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; } // Discover endpoints from the server, pick the preferred security mode EndpointDescription? endpoint; try { #pragma warning disable CS0618 using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl)); #pragma warning restore CS0618 #pragma warning disable CS0618 var endpoints = discoveryClient.GetEndpoints(null); #pragma warning restore CS0618 endpoint = endpoints .Where(e => e.SecurityMode == preferredSecurityMode) .FirstOrDefault() ?? endpoints.FirstOrDefault(); } catch { // Fallback: construct endpoint description manually endpoint = new EndpointDescription(endpointUrl); } var endpointConfig = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); #pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility var sessionFactory = new DefaultSessionFactory(); #pragma warning restore CS0618 var userIdentity = BuildUserIdentity(opts.UserIdentity); _session = await sessionFactory.CreateAsync( appConfig, configuredEndpoint, false, "ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken); // Detect server going offline via keep-alive failures Interlocked.Exchange(ref _connectionLostFired, 0); _session.KeepAlive += OnSessionKeepAlive; // Store options for monitored item creation _options = opts; // Create a default subscription for all monitored items _subscription = new Subscription(_session.DefaultSubscription) { DisplayName = opts.SubscriptionDisplayName, Priority = opts.SubscriptionPriority, PublishingEnabled = true, PublishingInterval = opts.PublishingIntervalMs, KeepAliveCount = (uint)opts.KeepAliveCount, LifetimeCount = (uint)opts.LifetimeCount, MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish }; _session.AddSubscription(_subscription); await _subscription.CreateAsync(cancellationToken); } public async Task DisconnectAsync(CancellationToken cancellationToken = default) { if (_subscription != null) { await _subscription.DeleteAsync(true); _subscription = null; } if (_session != null) { _session.KeepAlive -= OnSessionKeepAlive; await _session.CloseAsync(cancellationToken); _session = null; } _monitoredItems.Clear(); _callbacks.Clear(); } public async Task CreateSubscriptionAsync( string nodeId, Action onValueChanged, CancellationToken cancellationToken = default) { if (_subscription == null || _session == null) throw new InvalidOperationException("Not connected."); var handle = Guid.NewGuid().ToString(); var monitoredItem = new MonitoredItem(_subscription.DefaultItem) { DisplayName = nodeId, StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = _options.SamplingIntervalMs, QueueSize = (uint)_options.QueueSize, DiscardOldest = _options.DiscardOldest, Filter = BuildDataChangeFilter(_options.Deadband) }; _callbacks[handle] = onValueChanged; monitoredItem.Notification += (item, e) => { if (e.NotificationValue is MonitoredItemNotification notification) { var value = notification.Value?.Value; var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow; var statusCode = notification.Value?.StatusCode.Code ?? 0; if (_callbacks.TryGetValue(handle, out var cb)) { cb(nodeId, value, timestamp, statusCode); } } }; _subscription.AddItem(monitoredItem); await _subscription.ApplyChangesAsync(cancellationToken); _monitoredItems[handle] = monitoredItem; return handle; } public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default) { if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item)) { _subscription.RemoveItem(item); await _subscription.ApplyChangesAsync(cancellationToken); _monitoredItems.TryRemove(subscriptionHandle, out _); _callbacks.TryRemove(subscriptionHandle, out _); } } public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync( string nodeId, CancellationToken cancellationToken = default) { if (_session == null) throw new InvalidOperationException("Not connected."); var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }; var response = await _session.ReadAsync( null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), new ReadValueIdCollection { readValue }, cancellationToken); var result = response.Results[0]; return (result.Value, result.SourceTimestamp, result.StatusCode.Code); } public async Task WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default) { if (_session == null) throw new InvalidOperationException("Not connected."); var writeValue = new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }; var response = await _session.WriteAsync( null, new WriteValueCollection { writeValue }, cancellationToken); return response.Results[0].Code; } /// /// Called by the OPC UA SDK when a keep-alive response arrives (or fails). /// When CurrentState is bad, the server is unreachable. The once-only guard is an /// atomic compare-and-set, so a burst of failed keep-alives raises /// exactly once. /// private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e) { if (ServiceResult.IsBad(e.Status)) { if (Interlocked.Exchange(ref _connectionLostFired, 1) != 0) return; ConnectionLost?.Invoke(); } } public async ValueTask DisposeAsync() { await DisconnectAsync(); } private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options) { if (options is null) return null; return options.TokenType.ToUpperInvariant() switch { "USERNAMEPASSWORD" => new UserIdentity( options.Username, System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")), "X509CERTIFICATE" => new UserIdentity( X509CertificateLoader.LoadPkcs12FromFile( options.CertificatePath, options.CertificatePassword)), _ => null }; } private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband) { if (deadband is null) return null; var deadbandType = deadband.Type.ToUpperInvariant() switch { "PERCENT" => DeadbandType.Percent, _ => DeadbandType.Absolute }; return new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue, DeadbandType = (uint)deadbandType, DeadbandValue = deadband.Value }; } private static TimestampsToReturn MapTimestampsToReturn(string mode) => mode.ToUpperInvariant() switch { "SERVER" => TimestampsToReturn.Server, "BOTH" => TimestampsToReturn.Both, _ => TimestampsToReturn.Source }; private static string ResolveStorePath(string configured, string fallbackLeaf) => string.IsNullOrWhiteSpace(configured) ? Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", fallbackLeaf) : configured; } /// /// Factory that creates real OPC UA client instances using the OPC Foundation SDK. /// public class RealOpcUaClientFactory : IOpcUaClientFactory { private readonly OpcUaGlobalOptions _globalOptions; // DataConnectionLayer-014: a real logger must be threaded through to every // RealOpcUaClient this factory builds, otherwise the DCL-012 auto-accept-certificate // warning emitted in RealOpcUaClient.ConnectAsync sinks into NullLogger and is never // seen in production. The factory is constructed by DataConnectionFactory, which has // an ILoggerFactory available. private readonly ILoggerFactory _loggerFactory; public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { } public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions) : this(globalOptions, NullLoggerFactory.Instance) { } public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions, ILoggerFactory loggerFactory) { _globalOptions = globalOptions; _loggerFactory = loggerFactory; } public IOpcUaClient Create() => new RealOpcUaClient(_globalOptions, _loggerFactory.CreateLogger()); }