STRUCTURAL: no broken links/paths for this doc (links-report had zero rows); check_links.py confirms zero rows. All cited src paths verified on disk. STALE-STATUS (v1->v2): - Removed v1 'two separate Server/Admin processes' framing; documented the single role-gated Host binary + OTOPCUA_ROLES gate (src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs; AkkaClusterOptions.cs). - Server class is OtOpcUaSdkServer (not 'OtOpcUaServer'); it wires ONE OtOpcUaNodeManager via CreateMasterNodeManager, not one DriverNodeManager per driver. OtOpcUaSdkServer.cs:12-26. - Removed nonexistent OnServerStarted / LoadServerProperties overrides and the 'DriverNodeManagers' member (no such member; grep found none). CODE-REALITY (doc corrected to match source; no code changed): - Class name: OtOpcUaSdkServer : StandardServer — OtOpcUaSdkServer.cs:12. - Address space: OtOpcUaNodeManager : CustomNodeManager2, namespace 'https://zb.com/otopcua/ns', single 'OtOpcUa' root folder; push-driven via IOpcUaAddressSpaceSink — OtOpcUaNodeManager.cs:25,27,225-251. - Impersonation lives in OpcUaApplicationHost (not the SDK server). Uses IOpcUaUserAuthenticator, attaches a UserIdentity (NOT RoleBasedIdentity/ IRoleBearer — neither exists), Anonymous+X509 fall through to SDK default, failures -> BadIdentityTokenRejected (not BadIdentityTokenInvalid). OpcUaApplicationHost.cs:159-288. - Certificate stores default to PkiStoreRoot='pki' (relative to cwd), NOT %LOCALAPPDATA%. Substores own/issuer/trusted/rejected. AutoAcceptUntrustedClientCertificates default=false (doc had Security.AutoAcceptClientCertificates default=true; key does not exist). Removed RejectSHA1Certificates claim (not present). OpcUaApplicationHost.cs:51,71,298-355. - Security profiles: EnabledSecurityProfiles default = all three baseline profiles, one endpoint per profile; not 'resolved from ServerInstance.Security JSON, default None'. Endpoint path is .../OtOpcUa. OpcUaApplicationHost.cs:59-64,321. - Dispatch: CapabilityInvoker is one per (DriverInstance, IDriver); pipeline keyed (DriverInstanceId, hostName, DriverCapability). Enum member is 'Discover' (not 'Discovery'). Alarm surfaces route via AlarmSurfaceInvoker (SubscribeAlarmsAsync/UnsubscribeAlarmsAsync/AcknowledgeAsync), per-host fan-out. CapabilityInvoker.cs:7-19,61-156; AlarmSurfaceInvoker.cs:5-51; DriverCapability.cs:20-41. OTOPCUA0001 analyzer is category OtOpcUa.Resilience, severity Warning — UnwrappedCapabilityCallAnalyzer.cs:67; AnalyzerReleases.Shipped.md:10. - Authorization: removed nonexistent AuthorizationGate / NodeScopeResolver / Authorization:StrictMode / lax-strict mode / WriteAuthzPolicy. Documented the real permission-trie infra under Core/Authorization/ (PermissionTrie, TriePermissionEvaluator, NodeScope, UserAuthorizationState, AuthorizationDecision). - Config DB: optimistic concurrency is RowVersion (per-entity), not a 'DraftRevisionToken' (no such field). sp_PublishGeneration + sp_ComputeGenerationDiff verified in Configuration migrations. - Redundancy: ServiceLevel republished via SdkServiceLevelPublisher (IServiceLevelPublisher); ServiceLevelCalculator 0-255. Dropped invented 'RedundantServerArray' node; standard props are RedundancySupport + ServerUriArray. SdkServiceLevelPublisher.cs:9-58; ServiceLevelCalculator.cs:13-23. INLINE COMPLETENESS: documented EnabledSecurityProfiles binding key in the Transport section (inventory-diff G3 row owner).
12 KiB
OPC UA Server
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:
CreateMasterNodeManagerconstructs oneOtOpcUaNodeManager(src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs) — aCustomNodeManager2subclass that owns the writable address space under the namespacehttps://zb.com/otopcua/nsand a singleOtOpcUaroot folder organized under the standardObjectsfolder. It is wrapped in aMasterNodeManagerwith no additional core managers.OtOpcUaSdkServer.NodeManagerexposes the live node manager afterStartAsync, so the hosting layer can wrap it in aSdkAddressSpaceSink(src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs) and hand it toOpcUaPublishActor.
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (DriverInstanceActor → OpcUaPublishActor), and OpcUaPublishActor writes them into the node manager through the IOpcUaAddressSpaceSink seam. OtOpcUaNodeManager.EnsureFolder / EnsureVariable materialize the UNS folder + variable hierarchy; WriteValue / WriteAlarmState 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 / bulkhead) 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, thenIOpcUaUserAuthenticator.AuthenticateUserNameAsyncvalidates the credential (LdapUserAuthenticatorin production, a stub in tests). On success aUserIdentitycarrying the token is attached and the LDAP-derived roles are logged; on failureImpersonateEventArgs.IdentityValidationErroris set toBadIdentityTokenRejected.AnonymousIdentityTokenand 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 0–255 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 singleOtOpcUaNodeManagerand wraps it in aMasterNodeManagerwith no extra core managers.NodeManager— Public accessor exposing the liveOtOpcUaNodeManageronce the SDK has bootstrapped (null untilCreateMasterNodeManagerruns).
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.cs—StandardServersubclass wiring the single node managersrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs— programmaticApplicationConfiguration+ lifecycle +ImpersonateUserhook + ServerArray populationsrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs—CustomNodeManager2owning the writable address spacesrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs—IOpcUaAddressSpaceSinkadapter the actor system pushes intosrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs— publishes the redundancyServiceLevelnodesrc/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs— driver-agnostic discovery walk + alarm routingsrc/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs— process-local driver registration + lifecyclesrc/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs— Polly pipeline entry point for capability callssrc/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs— per-host fan-out wrapper forIAlarmSourcesrc/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/— permission trie + evaluator (PermissionTrie,PermissionTrieCache,TriePermissionEvaluator)