Resolve DA, A&C, and security spec gaps with ServerCapabilities, alarm methods, and modern profiles
Add ServerCapabilities/OperationLimits node, enable diagnostics, add OnModifyMonitoredItemsComplete override for DA compliance. Wire shelving, enable/disable, confirm, and addcomment handlers on alarm conditions with LocalTime/Quality event fields for Part 9 compliance. Add Aes128/Aes256 security profiles, X.509 certificate authentication, and AUDIT-prefixed auth logging. Fix flaky probe monitor test. Update docs for all changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,5 +42,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
|
||||
/// </summary>
|
||||
public string? CertificateSubject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lifetime of the auto-generated server certificate in months.
|
||||
/// Defaults to 60 months (5 years).
|
||||
/// </summary>
|
||||
public int CertificateLifetimeMonths { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
condition.Retain.Value = false;
|
||||
condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e);
|
||||
condition.OnAcknowledge = OnAlarmAcknowledge;
|
||||
condition.OnConfirm = OnAlarmConfirm;
|
||||
condition.OnAddComment = OnAlarmAddComment;
|
||||
condition.OnEnableDisable = OnAlarmEnableDisable;
|
||||
condition.OnShelve = OnAlarmShelve;
|
||||
condition.OnTimedUnshelve = OnAlarmTimedUnshelve;
|
||||
|
||||
// Add HasCondition reference from source to condition
|
||||
if (sourceVariable != null)
|
||||
@@ -425,6 +430,48 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceResult OnAlarmConfirm(
|
||||
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
|
||||
{
|
||||
Log.Information("Alarm confirmed: {Name} (Comment={Comment})",
|
||||
condition.ConditionName?.Value, comment?.Text);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private ServiceResult OnAlarmAddComment(
|
||||
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
|
||||
{
|
||||
Log.Information("Alarm comment added: {Name} — {Comment}",
|
||||
condition.ConditionName?.Value, comment?.Text);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private ServiceResult OnAlarmEnableDisable(
|
||||
ISystemContext context, ConditionState condition, bool enabling)
|
||||
{
|
||||
Log.Information("Alarm {Action}: {Name}",
|
||||
enabling ? "ENABLED" : "DISABLED", condition.ConditionName?.Value);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private ServiceResult OnAlarmShelve(
|
||||
ISystemContext context, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime)
|
||||
{
|
||||
alarm.SetShelvingState(context, shelving, oneShot, shelvingTime);
|
||||
Log.Information("Alarm {Action}: {Name} (OneShot={OneShot}, Time={Time}s)",
|
||||
shelving ? "SHELVED" : "UNSHELVED", alarm.ConditionName?.Value, oneShot,
|
||||
shelvingTime / 1000.0);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private ServiceResult OnAlarmTimedUnshelve(
|
||||
ISystemContext context, AlarmConditionState alarm)
|
||||
{
|
||||
alarm.SetShelvingState(context, false, false, 0);
|
||||
Log.Information("Alarm timed unshelve: {Name}", alarm.ConditionName?.Value);
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private void ReportAlarmEvent(AlarmInfo info, bool active)
|
||||
{
|
||||
var condition = info.ConditionNode;
|
||||
@@ -443,6 +490,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
condition.Message.Value = new LocalizedText("en", message);
|
||||
condition.SetSeverity(SystemContext, (EventSeverity)severity);
|
||||
|
||||
// Populate additional event fields
|
||||
if (condition.LocalTime != null)
|
||||
condition.LocalTime.Value = new TimeZoneDataType
|
||||
{
|
||||
Offset = (short)TimeZoneInfo.Local.BaseUtcOffset.TotalMinutes,
|
||||
DaylightSavingInOffset = TimeZoneInfo.Local.IsDaylightSavingTime(DateTime.Now)
|
||||
};
|
||||
if (condition.Quality != null)
|
||||
condition.Quality.Value = StatusCodes.Good;
|
||||
|
||||
// Retain while active or unacknowledged
|
||||
condition.Retain.Value = active || condition.AckedState?.Id?.Value == false;
|
||||
|
||||
@@ -1808,6 +1865,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
RestoreTransferredSubscriptions(transferredTagRefs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModifyMonitoredItemsComplete(ServerSystemContext context,
|
||||
IList<IMonitoredItem> monitoredItems)
|
||||
{
|
||||
foreach (var item in monitoredItems)
|
||||
Log.Debug("MonitoredItem modified: Id={Id}, SamplingInterval={Interval}ms",
|
||||
item.Id, item.SamplingInterval);
|
||||
}
|
||||
|
||||
private static string? GetNodeIdString(IMonitoredItem item)
|
||||
{
|
||||
if (item.ManagerHandle is NodeState node)
|
||||
|
||||
@@ -110,6 +110,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
ConfigureRedundancy(server);
|
||||
ConfigureHistoryCapabilities(server);
|
||||
ConfigureServerCapabilities(server);
|
||||
}
|
||||
|
||||
private void ConfigureRedundancy(IServerInternal server)
|
||||
@@ -264,6 +265,78 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureServerCapabilities(IServerInternal server)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dnm = server.DiagnosticsNodeManager;
|
||||
var ctx = server.DefaultSystemContext;
|
||||
|
||||
// Server profiles
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_ServerProfileArray,
|
||||
new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" });
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_LocaleIdArray,
|
||||
new[] { "en" });
|
||||
|
||||
// Limits
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024));
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024));
|
||||
|
||||
// OperationLimits
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds,
|
||||
(uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0);
|
||||
|
||||
// Diagnostics
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerDiagnostics_EnabledFlag, true);
|
||||
|
||||
Log.Information(
|
||||
"ServerCapabilities configured (OperationLimits, diagnostics enabled)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed to configure ServerCapabilities — capability discovery may not work for clients");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx,
|
||||
NodeId variableId, object value)
|
||||
{
|
||||
@@ -339,7 +412,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
|
||||
{
|
||||
Log.Warning("Authentication failed for user {Username}", userNameToken.UserName);
|
||||
Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
|
||||
}
|
||||
|
||||
@@ -371,12 +445,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Information("User {Username} authenticated with roles [{Roles}]",
|
||||
userNameToken.UserName, string.Join(", ", appRoles));
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
|
||||
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("User {Username} authenticated", userNameToken.UserName);
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
}
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
@@ -384,6 +459,35 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is X509IdentityToken x509Token)
|
||||
{
|
||||
var cert = x509Token.Certificate;
|
||||
var subject = cert?.Subject ?? "Unknown";
|
||||
|
||||
// Extract CN from certificate subject for display
|
||||
var cn = subject;
|
||||
var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase);
|
||||
if (cnStart >= 0)
|
||||
{
|
||||
cn = subject.Substring(cnStart + 3);
|
||||
var commaIdx = cn.IndexOf(',');
|
||||
if (commaIdx >= 0)
|
||||
cn = cn.Substring(0, commaIdx);
|
||||
}
|
||||
|
||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
||||
|
||||
// X.509 authenticated users get ReadOnly role by default
|
||||
if (_readOnlyRoleId != null)
|
||||
roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(x509Token), roles);
|
||||
Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}",
|
||||
cn, subject, cert?.Thumbprint);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
@@ -185,11 +186,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
// Check/create application certificate
|
||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
||||
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
||||
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
|
||||
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
||||
if (!certOk)
|
||||
{
|
||||
Log.Warning("Application certificate check failed, attempting to create...");
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
||||
@@ -203,15 +205,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
|
||||
{
|
||||
var cert = e.Certificate;
|
||||
var subject = cert?.Subject ?? "Unknown";
|
||||
var thumbprint = cert?.Thumbprint ?? "N/A";
|
||||
|
||||
if (_securityConfig.AutoAcceptClientCertificates)
|
||||
{
|
||||
e.Accept = true;
|
||||
Log.Debug("Client certificate auto-accepted: {Subject}", e.Certificate?.Subject);
|
||||
Log.Warning(
|
||||
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
|
||||
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Client certificate validation: {Error} for {Subject} — Accepted={Accepted}",
|
||||
e.Error?.StatusCode, e.Certificate?.Subject, e.Accept);
|
||||
Log.Warning(
|
||||
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
|
||||
e.Error?.StatusCode, subject, thumbprint, e.Accept);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +253,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
if (_authConfig.Ldap.Enabled || _authProvider != null)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
|
||||
|
||||
// X.509 certificate authentication is always available when security is configured
|
||||
if (_securityConfig.Profiles.Any(p =>
|
||||
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
|
||||
|
||||
if (policies.Count == 0)
|
||||
{
|
||||
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
|
||||
|
||||
@@ -30,6 +30,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
},
|
||||
["Aes128_Sha256_RsaOaep-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
||||
},
|
||||
["Aes128_Sha256_RsaOaep-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
||||
},
|
||||
["Aes256_Sha256_RsaPss-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
||||
},
|
||||
["Aes256_Sha256_RsaPss-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user