using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Text; using Opc.Ua; using Opc.Ua.Server; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.Historian; using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// /// Custom OPC UA server that creates the LmxNodeManager, handles user authentication, /// and exposes redundancy state through the standard server object. (OPC-001, OPC-012) /// public class LmxOpcUaServer : StandardServer { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly string _galaxyName; private readonly IMxAccessClient _mxAccessClient; private readonly PerformanceMetrics _metrics; private readonly HistorianDataSource? _historianDataSource; private readonly bool _alarmTrackingEnabled; private readonly AuthenticationConfiguration _authConfig; private readonly IUserAuthenticationProvider? _authProvider; private readonly RedundancyConfiguration _redundancyConfig; private readonly string? _applicationUri; private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator(); private readonly ConcurrentDictionary> _userAppRoles = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); private LmxNodeManager? _nodeManager; /// /// Gets the custom node manager that publishes the Galaxy-backed namespace. /// public LmxNodeManager? NodeManager => _nodeManager; /// /// Returns the application-level roles cached for the given username, or null if no roles are stored. /// Called by LmxNodeManager to enforce per-role write and alarm-ack permissions. /// public IReadOnlyList? GetUserAppRoles(string? username) { if (username != null && _userAppRoles.TryGetValue(username, out var roles)) return roles; return null; } /// /// Gets whether LDAP role-based access control is active. /// public bool LdapRolesEnabled => _authProvider is Domain.IRoleProvider; /// /// Gets the number of active OPC UA sessions currently connected to the server. /// public int ActiveSessionCount { get { try { return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; } catch { return 0; } } } public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null, RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null) { _galaxyName = galaxyName; _mxAccessClient = mxAccessClient; _metrics = metrics; _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; _authConfig = authConfig ?? new AuthenticationConfiguration(); _authProvider = authProvider; _redundancyConfig = redundancyConfig ?? new RedundancyConfiguration(); _applicationUri = applicationUri; } /// protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) { var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, LdapRolesEnabled ? (Func?>)GetUserAppRoles : null, LdapRolesEnabled); var nodeManagers = new List { _nodeManager }; return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); } /// protected override void OnServerStarted(IServerInternal server) { base.OnServerStarted(server); server.SessionManager.ImpersonateUser += OnImpersonateUser; ConfigureRedundancy(server); } private void ConfigureRedundancy(IServerInternal server) { var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled); try { // Set RedundancySupport via the diagnostics node manager var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport; var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode( redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState; if (redundancySupportNode != null) { redundancySupportNode.Value = (int)mode; redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false); Log.Information("Set RedundancySupport to {Mode}", mode); } // Set ServerUriArray for non-transparent redundancy if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0) { var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray; var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode( serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState; if (serverUriArrayNode != null) { serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray(); serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false); Log.Information("Set ServerUriArray to [{Uris}]", string.Join(", ", _redundancyConfig.ServerUris)); } else { Log.Warning("ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type"); } } // Set initial ServiceLevel var initialLevel = CalculateCurrentServiceLevel(true, true); SetServiceLevelValue(server, initialLevel); Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel); } catch (Exception ex) { Log.Warning(ex, "Failed to configure redundancy nodes — redundancy state may not be visible to clients"); } } /// /// Updates the server's ServiceLevel based on current runtime health. /// Called by the service layer when MXAccess or DB health changes. /// /// Whether the MXAccess connection is healthy. /// Whether the Galaxy repository database is reachable. public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) { var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected); try { if (ServerInternal != null) { SetServiceLevelValue(ServerInternal, level); } } catch (Exception ex) { Log.Debug(ex, "Failed to update ServiceLevel node"); } } private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected) { if (!_redundancyConfig.Enabled) return 255; // SDK default when redundancy is not configured var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); var baseLevel = isPrimary ? _redundancyConfig.ServiceLevelBase : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected); } private static void SetServiceLevelValue(IServerInternal server, byte level) { var serviceLevelNodeId = VariableIds.Server_ServiceLevel; var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode( serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState; if (serviceLevelNode != null) { serviceLevelNode.Value = level; serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false); } } private void OnImpersonateUser(Session session, ImpersonateEventArgs args) { if (args.NewIdentity is AnonymousIdentityToken anonymousToken) { if (!_authConfig.AllowAnonymous) throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled"); var roles = new List { Role.Anonymous }; if (_authConfig.AnonymousCanWrite) roles.Add(Role.AuthenticatedUser); args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles); Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite); return; } if (args.NewIdentity is UserNameIdentityToken userNameToken) { var password = userNameToken.DecryptedPassword ?? ""; if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password)) { Log.Warning("Authentication failed for user {Username}", userNameToken.UserName); throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password"); } var roles = new List { Role.AuthenticatedUser }; // Resolve LDAP-based roles when the provider supports it if (_authProvider is Domain.IRoleProvider roleProvider) { var appRoles = roleProvider.GetUserRoles(userNameToken.UserName); _userAppRoles[userNameToken.UserName] = appRoles; Log.Information("User {Username} authenticated with roles [{Roles}]", userNameToken.UserName, string.Join(", ", appRoles)); } else { Log.Information("User {Username} authenticated", userNameToken.UserName); } args.Identity = new RoleBasedIdentity( new UserIdentity(userNameToken), roles); return; } throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type"); } /// protected override ServerProperties LoadServerProperties() { var properties = new ServerProperties { ManufacturerName = "ZB MOM", ProductName = "LmxOpcUa Server", ProductUri = $"urn:{_galaxyName}:LmxOpcUa", SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0", BuildNumber = "1", BuildDate = System.DateTime.UtcNow }; return properties; } } }