Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
224
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
Normal file
224
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
|
||||
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
|
||||
/// </summary>
|
||||
public class AddressSpaceBuilder
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
|
||||
|
||||
/// <summary>
|
||||
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
|
||||
/// nodes.
|
||||
/// </summary>
|
||||
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
|
||||
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
|
||||
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
|
||||
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
var model = new AddressSpaceModel();
|
||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Build parent→children map
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root objects (parent not in hierarchy)
|
||||
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
||||
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
||||
|
||||
if (!knownIds.Contains(obj.ParentGobjectId))
|
||||
model.RootNodes.Add(nodeInfo);
|
||||
}
|
||||
|
||||
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
||||
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
||||
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
||||
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
||||
AddressSpaceModel model)
|
||||
{
|
||||
var node = new NodeInfo
|
||||
{
|
||||
GobjectId = obj.GobjectId,
|
||||
TagName = obj.TagName,
|
||||
BrowseName = obj.BrowseName,
|
||||
ParentGobjectId = obj.ParentGobjectId,
|
||||
IsArea = obj.IsArea
|
||||
};
|
||||
|
||||
if (!obj.IsArea)
|
||||
model.ObjectCount++;
|
||||
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
node.Attributes.Add(new AttributeNodeInfo
|
||||
{
|
||||
AttributeName = attr.AttributeName,
|
||||
FullTagReference = attr.FullTagReference,
|
||||
MxDataType = attr.MxDataType,
|
||||
IsArray = attr.IsArray,
|
||||
ArrayDimension = attr.ArrayDimension,
|
||||
PrimitiveName = attr.PrimitiveName ?? "",
|
||||
SecurityClassification = attr.SecurityClassification,
|
||||
IsHistorized = attr.IsHistorized,
|
||||
IsAlarm = attr.IsAlarm
|
||||
});
|
||||
|
||||
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
|
||||
model.VariableCount++;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
|
||||
{
|
||||
if (!attr.IsArray)
|
||||
return attr.FullTagReference;
|
||||
|
||||
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
|
||||
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
|
||||
: attr.FullTagReference;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node info for the address space tree.
|
||||
/// </summary>
|
||||
public class NodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy object identifier represented by this address-space node.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
|
||||
/// </summary>
|
||||
public string TagName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
|
||||
/// </summary>
|
||||
public string BrowseName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
|
||||
/// </summary>
|
||||
public int ParentGobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
|
||||
/// </summary>
|
||||
public bool IsArea { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute nodes published beneath this object.
|
||||
/// </summary>
|
||||
public List<AttributeNodeInfo> Attributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
|
||||
/// </summary>
|
||||
public List<NodeInfo> Children { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight description of an attribute node that will become an OPC UA variable.
|
||||
/// </summary>
|
||||
public class AttributeNodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy attribute name published under the object.
|
||||
/// </summary>
|
||||
public string AttributeName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
|
||||
/// </summary>
|
||||
public string FullTagReference { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
|
||||
/// </summary>
|
||||
public int MxDataType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is modeled as an array.
|
||||
/// </summary>
|
||||
public bool IsArray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the declared array length when the attribute is a fixed-size array.
|
||||
/// </summary>
|
||||
public int? ArrayDimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
|
||||
/// Empty for root-level attributes.
|
||||
/// </summary>
|
||||
public string PrimitiveName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
|
||||
/// </summary>
|
||||
public int SecurityClassification { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is historized.
|
||||
/// </summary>
|
||||
public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is an alarm.
|
||||
/// </summary>
|
||||
public bool IsAlarm { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building the address space model.
|
||||
/// </summary>
|
||||
public class AddressSpaceModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
|
||||
/// </summary>
|
||||
public List<NodeInfo> RootNodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of non-area Galaxy objects included in the model.
|
||||
/// </summary>
|
||||
public int ObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of variable nodes created from Galaxy attributes.
|
||||
/// </summary>
|
||||
public int VariableCount { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
132
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs
Normal file
132
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
|
||||
/// </summary>
|
||||
public static class AddressSpaceDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
|
||||
/// </summary>
|
||||
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
|
||||
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
|
||||
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
|
||||
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
|
||||
public static HashSet<int> FindChangedGobjectIds(
|
||||
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
|
||||
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
|
||||
{
|
||||
var changed = new HashSet<int>();
|
||||
|
||||
var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId);
|
||||
var newObjects = newHierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
// Added objects
|
||||
foreach (var id in newObjects.Keys)
|
||||
if (!oldObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
|
||||
// Removed objects
|
||||
foreach (var id in oldObjects.Keys)
|
||||
if (!newObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
|
||||
// Modified objects
|
||||
foreach (var kvp in newObjects)
|
||||
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
|
||||
changed.Add(kvp.Key);
|
||||
|
||||
// Attribute changes — group by gobject_id and compare
|
||||
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// All gobject_ids that have attributes in either old or new
|
||||
var allAttrGobjectIds = new HashSet<int>(oldAttrsByObj.Keys);
|
||||
allAttrGobjectIds.UnionWith(newAttrsByObj.Keys);
|
||||
|
||||
foreach (var id in allAttrGobjectIds)
|
||||
{
|
||||
if (changed.Contains(id))
|
||||
continue;
|
||||
|
||||
oldAttrsByObj.TryGetValue(id, out var oldAttrs);
|
||||
newAttrsByObj.TryGetValue(id, out var newAttrs);
|
||||
|
||||
if (!AttributeSetsEqual(oldAttrs, newAttrs))
|
||||
changed.Add(id);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
|
||||
/// </summary>
|
||||
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
|
||||
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
|
||||
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
|
||||
{
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
|
||||
|
||||
var expanded = new HashSet<int>(changed);
|
||||
var queue = new Queue<int>(changed);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (childrenByParent.TryGetValue(id, out var children))
|
||||
foreach (var childId in children)
|
||||
if (expanded.Add(childId))
|
||||
queue.Enqueue(childId);
|
||||
}
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
|
||||
{
|
||||
return a.TagName == b.TagName
|
||||
&& a.BrowseName == b.BrowseName
|
||||
&& a.ContainedName == b.ContainedName
|
||||
&& a.ParentGobjectId == b.ParentGobjectId
|
||||
&& a.IsArea == b.IsArea;
|
||||
}
|
||||
|
||||
private static bool AttributeSetsEqual(List<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? b)
|
||||
{
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.Count != b.Count) return false;
|
||||
|
||||
// Sort by a stable key and compare pairwise
|
||||
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
||||
var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
||||
|
||||
for (var i = 0; i < sortedA.Count; i++)
|
||||
if (!AttributesEqual(sortedA[i], sortedB[i]))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
|
||||
{
|
||||
return a.AttributeName == b.AttributeName
|
||||
&& a.FullTagReference == b.FullTagReference
|
||||
&& a.MxDataType == b.MxDataType
|
||||
&& a.IsArray == b.IsArray
|
||||
&& a.ArrayDimension == b.ArrayDimension
|
||||
&& a.PrimitiveName == b.PrimitiveName
|
||||
&& a.SecurityClassification == b.SecurityClassification
|
||||
&& a.IsHistorized == b.IsHistorized
|
||||
&& a.IsAlarm == b.IsAlarm;
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs
Normal file
92
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
|
||||
/// </summary>
|
||||
public static class DataValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a bridge VTQ snapshot into an OPC UA data value.
|
||||
/// </summary>
|
||||
/// <param name="vtq">The VTQ snapshot to convert.</param>
|
||||
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
|
||||
public static DataValue FromVtq(Vtq vtq)
|
||||
{
|
||||
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
|
||||
|
||||
var dataValue = new DataValue
|
||||
{
|
||||
Value = ConvertToOpcUaValue(vtq.Value),
|
||||
StatusCode = statusCode,
|
||||
SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc
|
||||
? vtq.Timestamp
|
||||
: vtq.Timestamp.ToUniversalTime(),
|
||||
ServerTimestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return dataValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
|
||||
/// </summary>
|
||||
/// <param name="dataValue">The OPC UA data value to convert.</param>
|
||||
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
|
||||
public static Vtq ToVtq(DataValue dataValue)
|
||||
{
|
||||
var quality = MapStatusCodeToQuality(dataValue.StatusCode);
|
||||
var timestamp = dataValue.SourceTimestamp != DateTime.MinValue
|
||||
? dataValue.SourceTimestamp
|
||||
: DateTime.UtcNow;
|
||||
|
||||
return new Vtq(dataValue.Value, timestamp, quality);
|
||||
}
|
||||
|
||||
private static object? ConvertToOpcUaValue(object? value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
bool _ => value,
|
||||
int _ => value,
|
||||
float _ => value,
|
||||
double _ => value,
|
||||
string _ => value,
|
||||
DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(),
|
||||
TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds
|
||||
short s => (int)s,
|
||||
long l => l,
|
||||
byte b => (int)b,
|
||||
bool[] _ => value,
|
||||
int[] _ => value,
|
||||
float[] _ => value,
|
||||
double[] _ => value,
|
||||
string[] _ => value,
|
||||
DateTime[] _ => value,
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static Quality MapStatusCodeToQuality(StatusCode statusCode)
|
||||
{
|
||||
var code = statusCode.Code;
|
||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
||||
|
||||
return code switch
|
||||
{
|
||||
StatusCodes.BadNotConnected => Quality.BadNotConnected,
|
||||
StatusCodes.BadCommunicationError => Quality.BadCommFailure,
|
||||
StatusCodes.BadConfigurationError => Quality.BadConfigError,
|
||||
StatusCodes.BadOutOfService => Quality.BadOutOfService,
|
||||
StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData,
|
||||
_ => Quality.Bad
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2924
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
2924
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
File diff suppressed because it is too large
Load Diff
528
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs
Normal file
528
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs
Normal file
@@ -0,0 +1,528 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
|
||||
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
|
||||
/// </summary>
|
||||
public class LmxOpcUaServer : StandardServer
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
||||
private readonly bool _alarmTrackingEnabled;
|
||||
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
||||
private readonly string? _applicationUri;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
|
||||
private readonly string _galaxyName;
|
||||
private readonly IHistorianDataSource? _historianDataSource;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
|
||||
private NodeId? _alarmAckRoleId;
|
||||
|
||||
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
|
||||
private NodeId? _readOnlyRoleId;
|
||||
private NodeId? _writeConfigureRoleId;
|
||||
private NodeId? _writeOperateRoleId;
|
||||
private NodeId? _writeTuneRoleId;
|
||||
|
||||
private readonly bool _runtimeStatusProbesEnabled;
|
||||
private readonly int _runtimeStatusUnknownTimeoutSeconds;
|
||||
private readonly int _mxAccessRequestTimeoutSeconds;
|
||||
private readonly int _historianRequestTimeoutSeconds;
|
||||
|
||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
||||
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
|
||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
|
||||
AlarmObjectFilter? alarmObjectFilter = null,
|
||||
bool runtimeStatusProbesEnabled = false,
|
||||
int runtimeStatusUnknownTimeoutSeconds = 15,
|
||||
int mxAccessRequestTimeoutSeconds = 30,
|
||||
int historianRequestTimeoutSeconds = 60)
|
||||
{
|
||||
_galaxyName = galaxyName;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_historianDataSource = historianDataSource;
|
||||
_alarmTrackingEnabled = alarmTrackingEnabled;
|
||||
_alarmObjectFilter = alarmObjectFilter;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
_applicationUri = applicationUri;
|
||||
_runtimeStatusProbesEnabled = runtimeStatusProbesEnabled;
|
||||
_runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds;
|
||||
_mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds;
|
||||
_historianRequestTimeoutSeconds = historianRequestTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
||||
/// </summary>
|
||||
public LmxNodeManager? NodeManager { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active OPC UA sessions currently connected to the server.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
|
||||
ApplicationConfiguration configuration)
|
||||
{
|
||||
// Resolve custom role NodeIds from the roles namespace
|
||||
ResolveRoleNodeIds(server);
|
||||
|
||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
||||
NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
|
||||
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
|
||||
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId,
|
||||
_alarmObjectFilter,
|
||||
_runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds,
|
||||
_mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds);
|
||||
|
||||
var nodeManagers = new List<INodeManager> { NodeManager };
|
||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
||||
}
|
||||
|
||||
private void ResolveRoleNodeIds(IServerInternal server)
|
||||
{
|
||||
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
|
||||
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
|
||||
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
|
||||
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
|
||||
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
|
||||
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
|
||||
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnServerStarted(IServerInternal server)
|
||||
{
|
||||
base.OnServerStarted(server);
|
||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
||||
|
||||
ConfigureRedundancy(server);
|
||||
ConfigureHistoryCapabilities(server);
|
||||
ConfigureServerCapabilities(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");
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureHistoryCapabilities(IServerInternal server)
|
||||
{
|
||||
if (_historianDataSource == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var dnm = server.DiagnosticsNodeManager;
|
||||
var ctx = server.DefaultSystemContext;
|
||||
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability,
|
||||
_alarmTrackingEnabled);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_MaxReturnDataValues,
|
||||
(uint)(_historianDataSource != null ? 10000 : 0));
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_UpdateDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteRawCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_UpdateEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true);
|
||||
|
||||
// Add aggregate function references under the AggregateFunctions folder
|
||||
var aggFolderNode = dnm?.FindPredefinedNode(
|
||||
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
|
||||
typeof(FolderState)) as FolderState;
|
||||
|
||||
if (aggFolderNode != null)
|
||||
{
|
||||
var aggregateIds = new[]
|
||||
{
|
||||
ObjectIds.AggregateFunction_Average,
|
||||
ObjectIds.AggregateFunction_Minimum,
|
||||
ObjectIds.AggregateFunction_Maximum,
|
||||
ObjectIds.AggregateFunction_Count,
|
||||
ObjectIds.AggregateFunction_Start,
|
||||
ObjectIds.AggregateFunction_End,
|
||||
ObjectIds.AggregateFunction_StandardDeviationPopulation
|
||||
};
|
||||
|
||||
foreach (var aggId in aggregateIds)
|
||||
{
|
||||
var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState;
|
||||
if (aggNode != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Reference already exists — skip
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Reference already exists — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions",
|
||||
aggregateIds.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("AggregateFunctions folder not found in predefined nodes");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed to configure HistoryServerCapabilities — history discovery may not work for clients");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
if (node != null)
|
||||
{
|
||||
node.Value = value;
|
||||
node.ClearChangeMasks(ctx, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the server's ServiceLevel based on current runtime health.
|
||||
/// Called by the service layer when MXAccess or DB health changes.
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(anonymousToken),
|
||||
new List<Role> { Role.Anonymous });
|
||||
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("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
|
||||
}
|
||||
|
||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
||||
|
||||
if (_authProvider is IRoleProvider roleProvider)
|
||||
{
|
||||
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
||||
|
||||
foreach (var appRole in appRoles)
|
||||
switch (appRole)
|
||||
{
|
||||
case AppRoles.ReadOnly:
|
||||
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
||||
break;
|
||||
case AppRoles.WriteOperate:
|
||||
if (_writeOperateRoleId != null)
|
||||
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
|
||||
break;
|
||||
case AppRoles.WriteTune:
|
||||
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
|
||||
break;
|
||||
case AppRoles.WriteConfigure:
|
||||
if (_writeConfigureRoleId != null)
|
||||
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
|
||||
break;
|
||||
case AppRoles.AlarmAck:
|
||||
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
|
||||
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
}
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(userNameToken), roles);
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = DateTime.UtcNow
|
||||
};
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs
Normal file
33
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
|
||||
/// </summary>
|
||||
public static class OpcUaQualityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts bridge quality values into OPC UA status codes.
|
||||
/// </summary>
|
||||
/// <param name="quality">The bridge quality value.</param>
|
||||
/// <returns>The OPC UA status code to publish.</returns>
|
||||
public static StatusCode ToStatusCode(Quality quality)
|
||||
{
|
||||
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA status code back into a bridge quality category.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The OPC UA status code to interpret.</param>
|
||||
/// <returns>The bridge quality category represented by the status code.</returns>
|
||||
public static Quality FromStatusCode(StatusCode statusCode)
|
||||
{
|
||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
325
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs
Normal file
325
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs
Normal file
@@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
|
||||
/// </summary>
|
||||
public class OpcUaServerHost : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
||||
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
|
||||
private readonly OpcUaConfiguration _config;
|
||||
private readonly IHistorianDataSource? _historianDataSource;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private readonly SecurityProfileConfiguration _securityConfig;
|
||||
private ApplicationInstance? _application;
|
||||
private LmxOpcUaServer? _server;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
||||
/// </summary>
|
||||
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
|
||||
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
|
||||
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
|
||||
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
|
||||
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
IHistorianDataSource? historianDataSource = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
SecurityProfileConfiguration? securityConfig = null,
|
||||
RedundancyConfiguration? redundancyConfig = null,
|
||||
AlarmObjectFilter? alarmObjectFilter = null,
|
||||
MxAccessConfiguration? mxAccessConfig = null,
|
||||
HistorianConfiguration? historianConfig = null)
|
||||
{
|
||||
_config = config;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_historianDataSource = historianDataSource;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
_alarmObjectFilter = alarmObjectFilter;
|
||||
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
|
||||
_historianConfig = historianConfig ?? new HistorianConfiguration();
|
||||
}
|
||||
|
||||
private readonly MxAccessConfiguration _mxAccessConfig;
|
||||
private readonly HistorianConfiguration _historianConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active node manager that holds the published Galaxy namespace.
|
||||
/// </summary>
|
||||
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently connected OPC UA client sessions.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
||||
/// </summary>
|
||||
public bool IsRunning => _server != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of opc.tcp base addresses the server is currently listening on.
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BaseAddresses
|
||||
{
|
||||
get
|
||||
{
|
||||
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
|
||||
return addrs != null ? addrs.ToList() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
|
||||
{
|
||||
get
|
||||
{
|
||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
|
||||
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UserTokenPolicies
|
||||
{
|
||||
get
|
||||
{
|
||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
|
||||
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the host and releases server resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
||||
/// </summary>
|
||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
|
||||
/// endpoint.
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
||||
var applicationUri = _config.ApplicationUri ?? namespaceUri;
|
||||
|
||||
// Resolve configured security profiles
|
||||
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
||||
foreach (var sp in securityPolicies)
|
||||
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
||||
|
||||
// Build PKI paths
|
||||
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki");
|
||||
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
|
||||
MaxSessionCount = _config.MaxSessions,
|
||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
||||
MinSessionTimeout = 10000,
|
||||
UserTokenPolicies = BuildUserTokenPolicies()
|
||||
};
|
||||
foreach (var policy in securityPolicies)
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
|
||||
var secConfig = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "own"),
|
||||
SubjectName = certSubject
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
||||
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
||||
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
|
||||
};
|
||||
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationUri = applicationUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = namespaceUri,
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = secConfig,
|
||||
|
||||
TransportQuotas = new TransportQuotas
|
||||
{
|
||||
OperationTimeout = 120000,
|
||||
MaxStringLength = 4 * 1024 * 1024,
|
||||
MaxByteStringLength = 4 * 1024 * 1024,
|
||||
MaxArrayLength = 65535,
|
||||
MaxMessageSize = 4 * 1024 * 1024,
|
||||
MaxBufferSize = 65535,
|
||||
ChannelLifetime = 600000,
|
||||
SecurityTokenLifetime = 3600000
|
||||
},
|
||||
|
||||
TraceConfiguration = new TraceConfiguration
|
||||
{
|
||||
OutputFilePath = null,
|
||||
TraceMasks = 0
|
||||
}
|
||||
};
|
||||
|
||||
await appConfig.Validate(ApplicationType.Server);
|
||||
|
||||
// Hook certificate validation logging
|
||||
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
||||
|
||||
_application = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ApplicationConfiguration = appConfig
|
||||
};
|
||||
|
||||
// Check/create application certificate
|
||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
||||
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, certLifetimeMonths);
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
|
||||
_alarmObjectFilter,
|
||||
_mxAccessConfig.RuntimeStatusProbesEnabled,
|
||||
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
|
||||
_mxAccessConfig.RequestTimeoutSeconds,
|
||||
_historianConfig.RequestTimeoutSeconds);
|
||||
await _application.Start(_server);
|
||||
|
||||
Log.Information(
|
||||
"OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
|
||||
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
|
||||
}
|
||||
|
||||
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.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={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
|
||||
e.Error?.StatusCode, subject, thumbprint, e.Accept);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the OPC UA application instance and releases its in-memory server objects.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_server?.Stop();
|
||||
Log.Information("OPC UA server stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error stopping OPC UA server");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_server = null;
|
||||
_application = null;
|
||||
}
|
||||
}
|
||||
|
||||
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
||||
{
|
||||
var policies = new UserTokenPolicyCollection();
|
||||
if (_authConfig.AllowAnonymous)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
||||
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");
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a configured redundancy mode string to the OPC UA <see cref="RedundancySupport" /> enum.
|
||||
/// </summary>
|
||||
public static class RedundancyModeResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver));
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured mode string to a <see cref="RedundancySupport" /> value.
|
||||
/// Returns <see cref="RedundancySupport.None" /> when redundancy is disabled or the mode is unrecognized.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode string from configuration (e.g., "Warm", "Hot").</param>
|
||||
/// <param name="enabled">Whether redundancy is enabled.</param>
|
||||
/// <returns>The resolved redundancy support mode.</returns>
|
||||
public static RedundancySupport Resolve(string mode, bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return RedundancySupport.None;
|
||||
|
||||
var resolved = (mode ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"warm" => RedundancySupport.Warm,
|
||||
"hot" => RedundancySupport.Hot,
|
||||
_ => RedundancySupport.None
|
||||
};
|
||||
|
||||
if (resolved == RedundancySupport.None)
|
||||
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot",
|
||||
mode);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs
Normal file
101
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy" /> instances.
|
||||
/// </summary>
|
||||
public static class SecurityProfileResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
||||
|
||||
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["None"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
},
|
||||
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
},
|
||||
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of valid profile names for validation and documentation.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
|
||||
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
||||
/// </summary>
|
||||
/// <param name="profileNames">The profile names from configuration.</param>
|
||||
/// <returns>A deduplicated list of server security policies.</returns>
|
||||
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
|
||||
{
|
||||
var resolved = new List<ServerSecurityPolicy>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in profileNames ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
|
||||
var trimmed = name.Trim();
|
||||
|
||||
if (!seen.Add(trimmed))
|
||||
{
|
||||
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
||||
resolved.Add(policy);
|
||||
else
|
||||
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
||||
trimmed, string.Join(", ", KnownProfiles.Keys));
|
||||
}
|
||||
|
||||
if (resolved.Count == 0)
|
||||
{
|
||||
Log.Warning("No valid security profiles configured — falling back to None");
|
||||
resolved.Add(KnownProfiles["None"]);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs
Normal file
33
src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs.
|
||||
/// </summary>
|
||||
public sealed class ServiceLevelCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the current ServiceLevel from a role-adjusted baseline and health state.
|
||||
/// </summary>
|
||||
/// <param name="baseLevel">The role-adjusted baseline (e.g., 200 for primary, 150 for secondary).</param>
|
||||
/// <param name="mxAccessConnected">Whether the MXAccess runtime connection is healthy.</param>
|
||||
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
|
||||
/// <returns>A ServiceLevel byte between 0 and 255.</returns>
|
||||
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
if (!mxAccessConnected && !dbConnected)
|
||||
return 0;
|
||||
|
||||
var level = baseLevel;
|
||||
|
||||
if (!mxAccessConnected)
|
||||
level -= 100;
|
||||
|
||||
if (!dbConnected)
|
||||
level -= 50;
|
||||
|
||||
return (byte)Math.Max(0, Math.Min(level, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user