Files
lmxopcua/docs/OpcUaServer.md
T
Joseph Doherty 2c1dc8bb14 docs(audit): OpcUaServer.md — accuracy + completeness pass
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).
2026-06-03 15:41:38 -04:00

12 KiB
Raw Blame History

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:

  • 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 / 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, then IOpcUaUserAuthenticator.AuthenticateUserNameAsync validates the credential (LdapUserAuthenticator 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)