Files
lmxopcua/docs/OpcUaServer.md
T
Joseph Doherty 4624141a5d test+docs: retire the stale EquipmentTagsDarkBatch4 skips, guard the deploy-time
tag gate, and fix three silently-broken OpcUaClient integration tests

Continues working through deferment.md's remaining items.

SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
  - REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
    the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
    E2E. The behaviour never went dark, only the v2 seeding did.
  - FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
    already compared RawTagPlan record-equal and RawTagPlan carries array /
    historize / alarm intent, so widening its corpus by two tags covers what
    three empty placeholder suites had been promising. Those suites are deleted.
  - DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
    and both dot-joint {{equip}}.X token suites (that resolver was deleted in
    Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.

G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.

OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.

DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-28 14:32:13 -04:00

13 KiB
Raw Blame History

OPC UA Server

⚠️ Correction (2026-07-28): GenericDriverNodeManager is NOT a production dispatch path. It is test scaffolding — its own source says so (Core/OpcUa/GenericDriverNodeManager.cs:71, "verified 07/#10: zero production references"). The production chain is OpcUaPublishActorIOpcUaAddressSpaceSink (SdkAddressSpaceSink) → OtOpcUaNodeManager, materialising the two v3 namespaces (.../raw and .../uns). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture through that class describe a design, not the running server. The same correction is already carried by AddressSpace.md and IncrementalSync.md; this file was missed in that pass.

The OPC UA server component (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/) hosts the OPC UA stack and exposes a browsable address space built from the registered drivers. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as IDriver implementations via the capability interfaces in src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/.

In v2 the Server and Admin processes were fused into a single role-gated ZB.MOM.WW.OtOpcUa.Host binary. Which subsystems start (OPC UA endpoint, Admin UI, control plane, driver runtime) is decided by the OTOPCUA_ROLES gate, not by running separate executables. See docs/ServiceHosting.md for the role model.

Composition

OtOpcUaSdkServer (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs) subclasses the OPC Foundation StandardServer and wires a single custom node manager:

  • CreateMasterNodeManager constructs one OtOpcUaNodeManager (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs) — a CustomNodeManager2 subclass that owns the writable address space under the namespace https://zb.com/otopcua/ns and a single OtOpcUa root folder organized under the standard Objects folder. It is wrapped in a MasterNodeManager with no additional core managers.
  • OtOpcUaSdkServer.NodeManager exposes the live node manager after StartAsync, so the hosting layer can wrap it in a SdkAddressSpaceSink (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs) and hand it to OpcUaPublishActor.

Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (DriverInstanceActorOpcUaPublishActor), and OpcUaPublishActor writes them into the node manager through the IOpcUaAddressSpaceSink seam. OtOpcUaNodeManager.EnsureFolder / EnsureVariable materialize the UNS folder + variable hierarchy; WriteValue / WriteAlarmCondition push runtime values and fire ClearChangeMasks so subscribed clients see updates.

The driver-agnostic walk that turns a driver's discovery into folder/variable calls lives in GenericDriverNodeManager (src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs): it walks ITagDiscovery.DiscoverAsync into an IAddressSpaceBuilder, captures alarm-condition sinks for variables flagged via IVariableHandle.MarkAsAlarmCondition, subscribes to IAlarmSource.OnAlarmEvent, and routes each alarm transition to the sink registered for its SourceNodeId.

The lifecycle facade OpcUaApplicationHost (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs) owns the ApplicationInstance + ApplicationConfiguration lifetime, starts the StandardServer, and attaches the ImpersonateUser hook (see Session impersonation).

Resilience and capability dispatch

Driver-capability calls (IReadable.ReadAsync, IWritable.WriteAsync, ITagDiscovery.DiscoverAsync, ISubscribable.SubscribeAsync/UnsubscribeAsync, the IHostConnectivityProbe probe loop, IAlarmSource surfaces, and the four IHistoryProvider reads) are routed through a CapabilityInvoker (src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs) so the Polly resilience pipeline (retry / timeout / breaker) applies. There is one invoker per (DriverInstance, IDriver) pair; all invokers share the process-singleton DriverResiliencePipelineBuilder, which keys pipelines on (DriverInstanceId, hostName, DriverCapability). Per-instance resilience options come from DriverTypeRegistry (the driver's tier) plus per-instance JSON overrides parsed from DriverInstance.ResilienceConfig by DriverResilienceOptionsParser.

The OTOPCUA0001 Roslyn analyzer (src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs, category OtOpcUa.Resilience, severity Warning) flags direct driver-capability calls that bypass the invoker.

Capability Surface Invoker entry point
Read IReadable.ReadAsync ExecuteAsync(DriverCapability.Read, host, …)
Write IWritable.WriteAsync ExecuteWriteAsync(host, isIdempotent, …) — disables retries for non-idempotent writes per WriteIdempotentAttribute / decisions #44-45, #143
Discovery ITagDiscovery.DiscoverAsync ExecuteAsync(DriverCapability.Discover, host, …)
Subscribe / Unsubscribe ISubscribable.SubscribeAsync/UnsubscribeAsync ExecuteAsync(DriverCapability.Subscribe, host, …)
HistoryRead (raw / processed / at-time / events) IHistoryProvider.*Async ExecuteAsync(DriverCapability.HistoryRead, host, …)
Alarm subscribe / unsubscribe / acknowledge IAlarmSource.SubscribeAlarmsAsync/UnsubscribeAlarmsAsync/AcknowledgeAsync via AlarmSurfaceInvoker (src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs), which fans out per host

The host name fed to the invoker comes from IPerCallHostResolver.ResolveHost(fullReference) when the driver implements it (multi-host drivers: AB CIP, Modbus, FOCAS, TwinCAT, AB Legacy resolve per device). Single-host drivers fall back to DriverInstanceId, preserving the per-instance pipeline-key semantics (decision #144).

Configuration

Tenant-scoped server wiring flows from the SQL Server Config DB, not from appsettings.json: ServerInstance + DriverInstance + Tag + NodeAcl rows are published as a generation by sp_PublishGeneration and loaded into the running process by the generation applier. The Admin UI (Blazor Server, docs/v2/admin-ui.md) is the operator surface — drafts accumulate edits and sp_ComputeGenerationDiff drives the DiffViewer preview before publish. Optimistic concurrency uses each entity's RowVersion; a stale edit fails the publish/save rather than silently overwriting. See docs/v2/config-db-schema.md for the schema.

Environmental knobs that aren't per-tenant — bind address, port, PKI store root, security profiles — are supplied to OpcUaApplicationHostOptions and resolved from appsettings.json on the Host project.

Transport

The server binds a TCP endpoint at opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpcUa (defaults 0.0.0.0:4840). The ApplicationConfiguration is built programmatically in OpcUaApplicationHost.BuildConfigurationAsync — there are no UA XML files unless ApplicationConfigPath is set. Security profiles are listed in OpcUaApplicationHostOptions.EnabledSecurityProfiles; by default all three baseline profiles are exposed (None, Basic256Sha256 + Sign, Basic256Sha256 + SignAndEncrypt) and the SDK publishes one endpoint descriptor per profile. Production deployments typically drop None. User token policies (Anonymous, UserName) are always attached; the UserName policy is SDK-encrypted with the server certificate so it works on None endpoints too. See docs/security.md for hardening.

Session impersonation

OpcUaApplicationHost subscribes to SessionManager.ImpersonateUser after ApplicationInstance.Start. The handler (HandleImpersonation) deals with the token types as follows:

  • UserNameIdentityToken → the password is decrypted, then IOpcUaUserAuthenticator.AuthenticateUserNameAsync validates the credential (LdapOpcUaUserAuthenticator in production, a stub in tests). On success a UserIdentity carrying the token is attached and the LDAP-derived roles are logged; on failure ImpersonateEventArgs.IdentityValidationError is set to BadIdentityTokenRejected.
  • AnonymousIdentityToken and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.

Decryption failures and authenticator exceptions also map to BadIdentityTokenRejected.

Authorization

Node-level authorization is backed by a permission trie under src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ (PermissionTrie, PermissionTrieBuilder, PermissionTrieCache, TriePermissionEvaluator, NodeScope, UserAuthorizationState, AuthorizationDecision). The trie is built from NodeAcl rows and a session's UserAuthorizationState, and an IPermissionEvaluator can return a per-tag AuthorizationDecision for Read / HistoryRead / Write / Browse independently. See docs/v2/acl-design.md.

Redundancy

Redundancy.Enabled = true on the ServerInstance activates the RedundancyStateActor + ServiceLevelCalculator (src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/). The OPC UA Server/ServiceLevel node (VariableIds.Server_ServiceLevel) is recomputed and republished via SdkServiceLevelPublisher (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs, wired as IServiceLevelPublisher) whenever role or driver-health changes; ServiceLevelCalculator produces a 0255 value where higher means more authoritative, so the primary advertises a higher ServiceLevel than the secondary. Clients also read the standard Server/ServerRedundancy/RedundancySupport and Server/ServerRedundancy/ServerUriArray properties the SDK exposes on the ServerObject. An apply-lease prevents two instances from concurrently applying a generation. See docs/Redundancy.md.

Peer endpoints are advertised through the standard Server.ServerArray property: OpcUaApplicationHost appends OpcUaApplicationHostOptions.PeerApplicationUris to IServerInternal.ServerUris after start so warm-redundancy clients can discover the partner.

Server class hierarchy

OtOpcUaSdkServer extends StandardServer

  • CreateMasterNodeManager — Constructs the single OtOpcUaNodeManager and wraps it in a MasterNodeManager with no extra core managers.
  • NodeManager — Public accessor exposing the live OtOpcUaNodeManager once the SDK has bootstrapped (null until CreateMasterNodeManager runs).

ApplicationName, ApplicationUri (urn:OtOpcUa), and ProductUri (https://zb.com/otopcua) come from OpcUaApplicationHostOptions, which the ApplicationConfiguration is built from in OpcUaApplicationHost.

Certificate handling

Certificate stores are directory-based under OpcUaApplicationHostOptions.PkiStoreRoot (default pki, relative to the host's working directory):

Store Path suffix
Own (application certificate) pki/own
Trusted issuers pki/issuer
Trusted peers pki/trusted
Rejected pki/rejected

OpcUaApplicationHostOptions.AutoAcceptUntrustedClientCertificates (default false) controls whether unknown client certificates are auto-trusted on first connection; production deployments leave it off and operators promote peers via the Admin UI. The application instance certificate is auto-created (SDK defaults: 2048-bit, 12-month lifetime) on first start against a fresh PKI tree, and the server certificate is always created — even for None-only deployments — because UserName token encryption needs it.

Key source files

  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.csStandardServer subclass wiring the single node manager
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs — programmatic ApplicationConfiguration + lifecycle + ImpersonateUser hook + ServerArray population
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.csCustomNodeManager2 owning the writable address space
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.csIOpcUaAddressSpaceSink adapter the actor system pushes into
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs — publishes the redundancy ServiceLevel node
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs — driver-agnostic discovery walk + alarm routing
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs — process-local driver registration + lifecycle
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs — Polly pipeline entry point for capability calls
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs — per-host fan-out wrapper for IAlarmSource
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ — permission trie + evaluator (PermissionTrie, PermissionTrieCache, TriePermissionEvaluator)