Two complementary pieces that together unblock the last Phase 7 exit-gate deferrals. ## #239 — Thread virtual + scripted-alarm IReadable through to DriverNodeManager OtOpcUaServer gains virtualReadable + scriptedAlarmReadable ctor params; shared across every DriverNodeManager it materializes so reads from a virtual-tag node in any driver's subtree route to the same engine instance. Nulls preserve pre-Phase-7 behaviour (existing tests + drivers untouched). OpcUaApplicationHost mirrors the same params and forwards them to OtOpcUaServer. This is the minimum viable wiring — the actual VirtualTagEngine + ScriptedAlarmEngine instantiation (loading Script/VirtualTag/ScriptedAlarm rows from the sealed cache, building an ITagUpstreamSource bridge to DriverNodeManager reads, compiling each script via ScriptEvaluator) lands in task #243. Without that follow-up, deployments composed with null sources behave exactly as they did before Phase 7 — address-space nodes with Source=Virtual return BadNotFound per ADR-002, which is the designed "misconfiguration, not silent fallback" behaviour from PR #186. ## #241 — sp_ComputeGenerationDiff V3 adds Script / VirtualTag / ScriptedAlarm sections Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7. Same CHECKSUM-based Modified detection the existing sections use. Logical ids: ScriptId / VirtualTagId / ScriptedAlarmId. Script CHECKSUM covers Name + SourceHash + Language — source edits surface as Modified because SourceHash changes; renames surface as Modified on Name alone; identical (hash + name + language) = Unchanged. VirtualTag + ScriptedAlarm CHECKSUMs cover their content columns. ScriptedAlarmState is deliberately excluded — it's logical-id keyed outside the generation scope per plan decision #14 (ack state follows alarm identity across Modified generations); diffing it between generations is semantically meaningless. Down() restores V2 (the NodeAcl-extended proc from migration 20260420000001). ## No new test count — both pieces are proven by existing suites The NodeSourceKind dispatch kernel is already covered by DriverNodeManagerSourceDispatchTests (PR #186). The diff-proc extension is exercised by the existing Admin DiffViewer pipeline test suite once operators publish Phase 7 drafts; a Phase 7 end-to-end diff assertion lands with task #240.
301 lines
13 KiB
C#
301 lines
13 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Wraps <see cref="ApplicationInstance"/> to bring the OPC UA server online — builds an
|
|
/// <see cref="ApplicationConfiguration"/> programmatically (no external XML file), ensures
|
|
/// the application certificate exists in the PKI store (auto-generates self-signed on first
|
|
/// run), starts the server, then walks each <see cref="DriverNodeManager"/> and invokes
|
|
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> against it so the driver's
|
|
/// discovery streams into the already-running server's address space.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|
{
|
|
private readonly OpcUaServerOptions _options;
|
|
private readonly DriverHost _driverHost;
|
|
private readonly IUserAuthenticator _authenticator;
|
|
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
|
private readonly AuthorizationGate? _authzGate;
|
|
private readonly NodeScopeResolver? _scopeResolver;
|
|
private readonly StaleConfigFlag? _staleConfigFlag;
|
|
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
|
private readonly Func<string, string?>? _resilienceConfigLookup;
|
|
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
|
|
|
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
|
|
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
|
|
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
|
|
// Phase-7 behaviour).
|
|
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
|
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
|
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
|
private ApplicationInstance? _application;
|
|
private OtOpcUaServer? _server;
|
|
private HealthEndpointsHost? _healthHost;
|
|
private bool _disposed;
|
|
|
|
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
|
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger,
|
|
DriverResiliencePipelineBuilder? pipelineBuilder = null,
|
|
AuthorizationGate? authzGate = null,
|
|
NodeScopeResolver? scopeResolver = null,
|
|
StaleConfigFlag? staleConfigFlag = null,
|
|
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
|
Func<string, string?>? resilienceConfigLookup = null,
|
|
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
|
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
|
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
|
|
{
|
|
_options = options;
|
|
_driverHost = driverHost;
|
|
_authenticator = authenticator;
|
|
_pipelineBuilder = pipelineBuilder ?? new DriverResiliencePipelineBuilder();
|
|
_authzGate = authzGate;
|
|
_scopeResolver = scopeResolver;
|
|
_staleConfigFlag = staleConfigFlag;
|
|
_tierLookup = tierLookup;
|
|
_resilienceConfigLookup = resilienceConfigLookup;
|
|
_equipmentContentLookup = equipmentContentLookup;
|
|
_virtualReadable = virtualReadable;
|
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
|
_loggerFactory = loggerFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public OtOpcUaServer? Server => _server;
|
|
|
|
/// <summary>
|
|
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
|
|
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
|
|
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> per registered driver so
|
|
/// the address space is populated before the first client connects.
|
|
/// </summary>
|
|
public async Task StartAsync(CancellationToken ct)
|
|
{
|
|
_application = new ApplicationInstance
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationType = ApplicationType.Server,
|
|
ApplicationConfiguration = BuildConfiguration(),
|
|
};
|
|
|
|
var hasCert = await _application.CheckApplicationInstanceCertificate(silent: true, minimumKeySize: CertificateFactory.DefaultKeySize).ConfigureAwait(false);
|
|
if (!hasCert)
|
|
throw new InvalidOperationException(
|
|
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
|
|
|
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
|
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
|
await _application.Start(_server).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
|
_options.EndpointUrl, _server.DriverNodeManagers.Count);
|
|
|
|
// Phase 6.1 Stream C: health endpoints on :4841 (loopback by default — see
|
|
// HealthEndpointsHost remarks for the Windows URL-ACL tradeoff).
|
|
if (_options.HealthEndpointsEnabled)
|
|
{
|
|
_healthHost = new HealthEndpointsHost(
|
|
_driverHost,
|
|
_loggerFactory.CreateLogger<HealthEndpointsHost>(),
|
|
usingStaleConfig: _staleConfigFlag is null ? null : () => _staleConfigFlag.IsStale,
|
|
prefix: _options.HealthEndpointsPrefix);
|
|
_healthHost.Start();
|
|
}
|
|
|
|
// Drive each driver's discovery through its node manager. The node manager IS the
|
|
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
|
// its internal map and wires OnAlarmEvent → sink routing.
|
|
//
|
|
// ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an
|
|
// Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync
|
|
// so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders +
|
|
// the five identifier properties (decision #121) are in place. DiscoverAsync then
|
|
// streams the driver's native shape on top; Tag rows bound to Equipment already
|
|
// materialized via the walker don't get duplicated because the driver's DiscoverAsync
|
|
// output is authoritative for its own native references only.
|
|
foreach (var nodeManager in _server.DriverNodeManagers)
|
|
{
|
|
var driverId = nodeManager.Driver.DriverInstanceId;
|
|
try
|
|
{
|
|
if (_equipmentContentLookup is not null)
|
|
{
|
|
var content = _equipmentContentLookup(driverId);
|
|
if (content is not null)
|
|
{
|
|
ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content);
|
|
_logger.LogInformation(
|
|
"UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}",
|
|
content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId);
|
|
}
|
|
}
|
|
|
|
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
|
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
|
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Per decision #12: driver exceptions isolate — log and keep the server serving
|
|
// the other drivers' subtrees. Re-building this one takes a Reinitialize call.
|
|
_logger.LogError(ex, "Discovery failed for driver {Driver}; subtree faulted", driverId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private ApplicationConfiguration BuildConfiguration()
|
|
{
|
|
Directory.CreateDirectory(_options.PkiStoreRoot);
|
|
|
|
var cfg = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationUri = _options.ApplicationUri,
|
|
ApplicationType = ApplicationType.Server,
|
|
ProductUri = "urn:OtOpcUa:Server",
|
|
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
|
|
SubjectName = "CN=" + _options.ApplicationName,
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "issuers"),
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "trusted"),
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "rejected"),
|
|
},
|
|
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
|
AddAppCertToTrustedStore = true,
|
|
},
|
|
|
|
TransportConfigurations = new TransportConfigurationCollection(),
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
|
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
BaseAddresses = new StringCollection { _options.EndpointUrl },
|
|
SecurityPolicies = BuildSecurityPolicies(),
|
|
UserTokenPolicies = BuildUserTokenPolicies(),
|
|
MinRequestThreadCount = 5,
|
|
MaxRequestThreadCount = 100,
|
|
MaxQueuedRequestCount = 200,
|
|
},
|
|
|
|
TraceConfiguration = new TraceConfiguration(),
|
|
};
|
|
|
|
cfg.Validate(ApplicationType.Server).GetAwaiter().GetResult();
|
|
|
|
if (cfg.SecurityConfiguration.AutoAcceptUntrustedCertificates)
|
|
{
|
|
cfg.CertificateValidator.CertificateValidation += (_, e) =>
|
|
{
|
|
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
|
e.Accept = true;
|
|
};
|
|
}
|
|
|
|
return cfg;
|
|
}
|
|
|
|
private ServerSecurityPolicyCollection BuildSecurityPolicies()
|
|
{
|
|
var policies = new ServerSecurityPolicyCollection
|
|
{
|
|
// Keep the None policy present so legacy clients can discover + browse. Locked-down
|
|
// deployments remove this by setting Ldap.Enabled=true + dropping None here; left in
|
|
// for PR 19 so the PR 17 test harness continues to pass unchanged.
|
|
new ServerSecurityPolicy
|
|
{
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
},
|
|
};
|
|
|
|
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)
|
|
{
|
|
policies.Add(new ServerSecurityPolicy
|
|
{
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
});
|
|
}
|
|
|
|
return policies;
|
|
}
|
|
|
|
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
|
{
|
|
var tokens = new UserTokenPolicyCollection
|
|
{
|
|
new UserTokenPolicy(UserTokenType.Anonymous)
|
|
{
|
|
PolicyId = "Anonymous",
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
},
|
|
};
|
|
|
|
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt
|
|
&& _options.Ldap.Enabled)
|
|
{
|
|
tokens.Add(new UserTokenPolicy(UserTokenType.UserName)
|
|
{
|
|
PolicyId = "UserName",
|
|
// Passwords must ride an encrypted channel — scope this token to Basic256Sha256
|
|
// so the stack rejects any attempt to send UserName over the None endpoint.
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
});
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
try
|
|
{
|
|
_server?.Stop();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "OPC UA server stop threw during dispose");
|
|
}
|
|
|
|
if (_healthHost is not null)
|
|
{
|
|
try { await _healthHost.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "Health endpoints host dispose threw"); }
|
|
}
|
|
await Task.CompletedTask;
|
|
}
|
|
}
|