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:
Joseph Doherty
2026-04-06 22:02:05 -04:00
parent 41f0e9ec4c
commit 6d47687573
12 changed files with 345 additions and 20 deletions

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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");
}

View File

@@ -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");

View File

@@ -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
}
};