using System.Net; using System.Net.Sockets; using System.Reflection; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// A booted server for the Server-object event tests (Gitea #587 / #588): one native condition on a raw /// device wired to two equipment notifiers, one scripted condition under , an API-key /// principal and anonymous, optionally an encrypted endpoint and server auditing. /// internal sealed class ServerEventRig : IAsyncDisposable { public const string Cluster = "SITE-A"; public const string RawDeviceFolder = "Plant/Modbus/dev1"; public const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; public const string ScriptedAlarmId = "scripted-eqa-hi"; public const string EqA = "EqA"; public const string EqB = "EqB"; public const string KeyPrincipal = "event-probe"; public const string KeyId = "k-server-events"; private readonly string _pkiRoot; private readonly List _pending = []; private OpcUaApplicationHost _host = null!; private OtOpcUaSdkServer _server = null!; private int _port; private OpcUaApplicationHostOptions _hostOptions = null!; private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot; public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa"; public OtOpcUaNodeManager Nm => _server.NodeManager!; /// /// The roles the apikey session activates with (read at each activation). Defaults to the two /// server-wide roles; a flag-off test sets it from ApiKeyInterimRoleMapper.Map over its own grants, as /// the production authenticator does. /// public IReadOnlyList KeyRoles { get; set; } = [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate]; public SdkAddressSpaceSink Sink { get; private set; } = null!; public static async Task StartAsync(string name, bool encrypted, CancellationToken ct) { var rig = new ServerEventRig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}")); rig._hostOptions = new OpcUaApplicationHostOptions { ApplicationName = $"OtOpcUa.{name}", ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}", PublicHostname = "127.0.0.1", PkiStoreRoot = rig._pkiRoot, EnabledSecurityProfiles = encrypted ? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] : [OpcUaSecurityProfile.None], AutoAcceptUntrustedClientCertificates = true, }; (rig._host, rig._server) = await OpcUaHostStart.StartAsync( port => { rig._port = port; rig._hostOptions.OpcUaPort = port; return rig.NewHost(); }, () => new OtOpcUaSdkServer(), ct); rig.Sink = new SdkAddressSpaceSink(rig.Nm); return rig; } private async Task BootAsync(CancellationToken ct) { _server = new OtOpcUaSdkServer(); _host = NewHost(); await _host.StartAsync(_server, ct); Sink = new SdkAddressSpaceSink(Nm); } private OpcUaApplicationHost NewHost() => new(_hostOptions, NullLogger.Instance, new KeyPresentingAuthenticator(() => KeyRoles)); /// /// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process /// restart as a client sees it). The address space is EMPTY afterwards: the caller re-creates its nodes and /// re-publishes values, as a redeploy would. runs once the old server is gone /// and before the new one binds, so a test can observe the outage. /// public async Task RestartAsync(CancellationToken ct, Func? whileDown = null) { await _host.DisposeAsync(); if (whileDown is not null) await whileDown(); await BootAsync(ct); } /// Wires the authorizer over the given keys and grants, enforcing or not. public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) => Enforce(enabled, keys, ScopePaths(), grants); /// Wires the authorizer over the given keys, scope paths and grants, enforcing or not. public DataPlaneAuthorityState Enforce( bool enabled, ApiKey[] keys, IReadOnlyDictionary> scopePaths, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, grants, scopePaths); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System); return state; } public static ApiKey Key() => new() { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = KeyPrincipal, Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1), }; public static NodeAcl ClusterGrant(string principal, string id, NodePermissions permissions) => new() { NodeAclId = id, ClusterId = Cluster, Principal = principal, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = permissions, }; public static NodeAcl EquipmentGrant(string equipment, NodePermissions permissions) => new() { NodeAclId = $"acl-{equipment}", ClusterId = Cluster, Principal = OpcUaPrincipals.Anonymous, ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = permissions, }; private static IReadOnlyDictionary> ScopePaths() => new Dictionary> { [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])], [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])], }; /// The native condition, wired as an event source of both equipment folders. public void MaterialiseNative() { Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); Sink.MaterialiseAlarmCondition( RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); EnsureEquipment(); Sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns); } /// The scripted condition under . public void MaterialiseScripted() { EnsureEquipment(); Sink.MaterialiseAlarmCondition( ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false); } /// /// Adds a raw value variable under (the folder is ensured), for tests that /// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers. /// public string EnsureRawVariable(string name, string dataType, bool writable, string? historianTagname = null) { Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); var id = $"{RawDeviceFolder}/{name}"; Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw, historianTagname); return id; } /// The server-stable nsu= form of a raw-realm string NodeId. public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}"; /// The API-key credential admits (username is always apikey). public static string KeyPassword => $"otua_{KeyId}_secret"; public void EnsureEquipment() { Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns); Sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns); } /// Projects onto a condition, as the engine or a driver would. public void Project(bool native, AlarmConditionSnapshot state) => Sink.WriteAlarmCondition( native ? RawAlarmPath : ScriptedAlarmId, state, DateTime.UtcNow, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns); /// /// Re-projects from a pool thread, the way the engine's or the driver's answer /// to a routed method call arrives — it contends for the node manager's lock like the real one does. /// public void ProjectLater(bool native, AlarmConditionSnapshot state) { lock (_pending) _pending.Add(Task.Run(() => Project(native, state))); } /// Waits for every re-projection to have run. public async Task DrainProjectionsAsync() { Task[] pending; lock (_pending) pending = [.. _pending]; await Task.WhenAll(pending); } /// The condition's NodeId in the session's namespaces. public static NodeId ConditionNodeId(ISession session, bool native) => native ? new NodeId(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)) : new NodeId(ScriptedAlarmId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri)); public static NodeId UnsNodeId(ISession session, string id) { var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); ns.ShouldBeGreaterThan((ushort)0); return new NodeId(id, ns); } /// Switches the server's Auditing flag (no configuration seam reaches it in this repo). public void SetAuditing(bool on) { var server = typeof(Opc.Ua.Server.CustomNodeManager2) .GetProperty("Server", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! .GetValue(Nm)!; server.GetType() .GetProperty("Auditing", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! .GetSetMethod(nonPublic: true)! .Invoke(server, [on]); } public async Task ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false) { var pki = TestClientSecurity.AllocatePkiRoot(); var security = TestClientSecurity.Build(pki); security.ApplicationCertificate.SubjectName = "CN=OtOpcUa.ServerEventClient"; var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.ServerEventClient", ApplicationUri = $"urn:OtOpcUa.ServerEventClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; if (encrypted) { var app = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationName = appConfig.ApplicationName, ApplicationType = ApplicationType.Client }; app.ApplicationConfiguration = appConfig; await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct); } var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", encrypted, DefaultTelemetry.Create(_ => { }), ct); if (encrypted) endpoint!.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); var identity = anonymous ? new UserIdentity(new AnonymousIdentityToken()) : new UserIdentity(new UserNameIdentityToken { UserName = "apikey", DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"), }); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)), updateBeforeConnect: false, checkDomain: false, sessionName: "ServerEventRig", sessionTimeout: 60_000, identity: identity, preferredLocales: null, ct: ct); } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } /// /// Admits apikey as carrying (used only while /// enforcement is off); anonymous falls through to the anonymous principal. /// private sealed class KeyPresentingAuthenticator(Func> roles) : IOpcUaUserAuthenticator { /// public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult( string.Equals(username, "apikey", StringComparison.Ordinal) && password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal) ? OpcUaUserAuthResult.AllowKey(KeyPrincipal, roles(), KeyId) : OpcUaUserAuthResult.Deny("Invalid API key.")); } } /// Records every event each monitored item received, in order, with the fields the tests select. internal sealed class RecordedEvents { // The select clauses, in order — see AddEventItem. public const int EventIdField = 0; public const int MessageField = 1; public const int EventTypeField = 2; public const int AckedField = 3; public const int ConfirmedField = 4; public const int EnabledField = 5; public const int ShelvingField = 6; public const int CommentField = 7; public const int ChangesField = 8; public const int SourceNodeField = 9; public const int UnshelveTimeField = 10; public const int ActiveField = 11; public const int SeverityField = 12; public const int RetainField = 13; private readonly object _gate = new(); private readonly List<(uint Handle, VariantCollection Fields)> _events = []; public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) { lock (_gate) { foreach (var e in notification.Events) _events.Add((e.ClientHandle, e.EventFields)); } } /// Every event received, in arrival order. public IReadOnlyList Of(MonitoredItem item) { lock (_gate) return _events.Where(e => e.Handle == item.ClientHandle).Select(e => e.Fields).ToList(); } /// The condition events (they carry AckedState/Id; audit and refresh events do not). public IReadOnlyList ConditionEvents(MonitoredItem item, string message) => Of(item).Where(f => f[AckedField].Value is bool && Message(f) == message).ToList(); public bool Has(MonitoredItem item, string message) => Of(item).Any(f => Message(f) == message); public bool HasType(MonitoredItem item, NodeId type) => Of(item).Any(f => f[EventTypeField].Value as NodeId == type); public byte[] LatestEventId(MonitoredItem item, string message) => (byte[])Of(item).Last(f => Message(f) == message && f[AckedField].Value is bool)[EventIdField].Value; public static string? Message(VariantCollection f) => (f[MessageField].Value as LocalizedText)?.Text; public static async Task SubscribeAsync(ISession session, RecordedEvents events, CancellationToken ct) { var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = events.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); return subscription; } public static MonitoredItem AddEventItem(Subscription subscription, NodeId source) { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id])); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.ConfirmedState, BrowseNames.Id])); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id])); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.CurrentState, BrowseNames.Id])); filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Comment); filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, BrowseNames.Changes); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.UnshelveTime])); filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id])); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity); filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = source, AttributeId = Attributes.EventNotifier, Filter = filter, QueueSize = 1000, SamplingInterval = 0, }; subscription.AddItem(item); return item; } public static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } condition().ShouldBeTrue("condition not met within timeout"); } }