Files
lmxopcua/docs/OpcUaServer.md
Joseph Doherty 985b7aba26 Doc refresh (task #202) — core architecture docs for multi-driver OtOpcUa
Rewrite seven core-architecture docs to match the shipped multi-driver platform.
The v1 single-driver LmxNodeManager framing is replaced with the Core +
capability-interface model — Galaxy is now one driver of seven, and each doc
points at the current class names + source paths.

What changed per file:
- OpcUaServer.md — OtOpcUaServer as StandardServer host; per-driver
  DriverNodeManager + CapabilityInvoker wiring; Config-DB-driven configuration
  (sp_PublishGeneration, DraftRevisionToken, Admin UI); Phase 6.2
  AuthorizationGate integration.
- AddressSpace.md — GenericDriverNodeManager.BuildAddressSpaceAsync walks
  ITagDiscovery.DiscoverAsync and streams DriverAttributeInfo through
  IAddressSpaceBuilder; CapturingBuilder registers alarm-condition sinks;
  per-driver NodeId schemes replace the fixed ns=1;s=ZB root.
- ReadWriteOperations.md — OnReadValue / OnWriteValue dispatch to
  IReadable.ReadAsync / IWritable.WriteAsync through CapabilityInvoker,
  honoring WriteIdempotentAttribute (#143); two-layer authorization
  (WriteAuthzPolicy + Phase 6.2 AuthorizationGate).
- Subscriptions.md — ISubscribable.SubscribeAsync/UnsubscribeAsync is the
  capability surface; STA-thread story is now Galaxy-specific (StaPump inside
  Driver.Galaxy.Host), other drivers are free-threaded.
- AlarmTracking.md — IAlarmSource is optional; AlarmSurfaceInvoker wraps
  Subscribe/Ack/Unsubscribe with fan-out by IPerCallHostResolver and the
  no-retry AlarmAcknowledge pipeline (#143); CapturingBuilder registers sinks
  at build time.
- DataTypeMapping.md — DriverDataType + SecurityClassification are the
  driver-agnostic enums; per-driver mappers (GalaxyProxyDriver inline,
  AbCipDataType, ModbusDriver, etc.); SecurityClassification is metadata only,
  ACL enforcement is at the server layer.
- IncrementalSync.md — IRediscoverable covers backend-change signals;
  sp_ComputeGenerationDiff + DiffViewer drive generation-level change
  detection; IDriver.ReinitializeAsync is the in-process recovery path.
2026-04-20 01:33:28 -04:00

8.2 KiB

OPC UA Server

The OPC UA server component (src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs) hosts the OPC UA stack and exposes one browsable subtree per registered driver. 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/ZB.MOM.WW.OtOpcUa.Core.Abstractions/.

Composition

OtOpcUaServer subclasses the OPC Foundation StandardServer and wires:

  • A DriverHost (src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs) which registers drivers and holds the per-instance IDriver references.
  • One DriverNodeManager per registered driver (src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs), constructed in CreateMasterNodeManager. Each manager owns its own namespace URI (urn:OtOpcUa:{DriverInstanceId}) and exposes the driver as a subtree under the standard Objects folder.
  • A CapabilityInvoker (src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs) per driver instance, keyed on (DriverInstanceId, HostName, DriverCapability) against the shared DriverResiliencePipelineBuilder. Every Read/Write/Discovery/Subscribe/HistoryRead/AlarmSubscribe call on the driver flows through this invoker so the Polly pipeline (retry / timeout / breaker / bulkhead) applies. The OTOPCUA0001 Roslyn analyzer enforces the wrapping at compile time.
  • An IUserAuthenticator (LDAP in production, injected stub in tests) for UserName token validation in the ImpersonateUser hook.
  • Optional AuthorizationGate + NodeScopeResolver (Phase 6.2) that sit in front of every dispatch call. In lax mode the gate passes through when the identity lacks LDAP groups so existing integration tests keep working; strict mode (Authorization:StrictMode = true) denies those cases.

OtOpcUaServer.DriverNodeManagers exposes the materialized list so the hosting layer can walk each one post-start and call GenericDriverNodeManager.BuildAddressSpaceAsync(manager) — the manager is passed as its own IAddressSpaceBuilder.

Configuration

Server wiring used to live in appsettings.json. It now flows from the SQL Server Config DB: ServerInstance + DriverInstance + Tag + NodeAcl rows are published as a generation via 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; sp_ComputeGenerationDiff drives the DiffViewer preview; a UNS drag-reorder carries a DraftRevisionToken so Confirm re-checks against the current draft and returns 409 if it advanced (decision #161). See docs/v2/config-db-schema.md for the schema.

Environmental knobs that aren't per-tenant (bind address, port, PKI path) still live in appsettings.json on the Server project; everything tenant-scoped moved to the Config DB.

Transport

The server binds one TCP endpoint per ServerInstance (default opc.tcp://0.0.0.0:4840). The ApplicationConfiguration is built programmatically in the OpcUaApplicationHost — there are no UA XML files. Security profiles (None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt) are resolved from the ServerInstance.Security JSON at startup; the default profile is still None for backward compatibility. User token policies (Anonymous, UserName) are attached based on whether LDAP is configured. See docs/security.md for hardening.

Session impersonation

OtOpcUaServer.OnImpersonateUser handles the three token types:

  • AnonymousIdentityToken → default anonymous UserIdentity.
  • UserNameIdentityTokenIUserAuthenticator.AuthenticateAsync validates the credential (LdapUserAuthenticator in production). On success, the resolved display name + LDAP-derived roles are wrapped in a RoleBasedIdentity that implements IRoleBearer. DriverNodeManager.OnWriteValue reads these roles via context.UserIdentity is IRoleBearer and applies WriteAuthzPolicy per write.
  • Anything else → BadIdentityTokenInvalid.

The Phase 6.2 AuthorizationGate runs on top of this baseline: when configured it consults the cluster's permission trie (loaded from NodeAcl rows) using the session's UserAuthorizationState and can deny Read / HistoryRead / Write / Browse independently per tag. See docs/v2/acl-design.md.

Dispatch

Every service call the stack hands to DriverNodeManager is translated to the driver's capability interface and routed through CapabilityInvoker:

Service Capability Invoker method
Read IReadable.ReadAsync ExecuteAsync(DriverCapability.Read, host, …)
Write IWritable.WriteAsync ExecuteWriteAsync(host, isIdempotent, …) — honors WriteIdempotentAttribute (#143)
CreateMonitoredItems / DeleteMonitoredItems ISubscribable.SubscribeAsync/UnsubscribeAsync ExecuteAsync(DriverCapability.Subscribe, host, …)
HistoryRead (raw / processed / at-time / events) IHistoryProvider.*Async ExecuteAsync(DriverCapability.HistoryRead, host, …)
ConditionRefresh / Acknowledge IAlarmSource.*Async via AlarmSurfaceInvoker (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 with per-device options). Single-host drivers fall back to DriverInstanceId, preserving pre-Phase-6.1 pipeline-key semantics (decision #144).

Redundancy

Redundancy.Enabled = true on the ServerInstance activates the RedundancyCoordinator + ServiceLevelCalculator (src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/). Standard OPC UA redundancy nodes (Server/ServerRedundancy/RedundancySupport, ServerUriArray, Server/ServiceLevel) are populated on startup; ServiceLevel recomputes whenever any driver's DriverHealth changes. The apply-lease mechanism prevents two instances from concurrently applying a generation. See docs/Redundancy.md.

Server class hierarchy

OtOpcUaServer extends StandardServer

  • CreateMasterNodeManager — Iterates _driverHost.RegisteredDriverIds, builds one DriverNodeManager per driver with its own CapabilityInvoker + resilience options (tier from DriverTypeRegistry, per-instance JSON overrides from DriverInstance.ResilienceConfig via DriverResilienceOptionsParser). The managers are wrapped in a MasterNodeManager with no additional core managers.
  • OnServerStarted — Hooks SessionManager.ImpersonateUser for LDAP auth. Redundancy + server-capability population happens via OpcUaApplicationHost.
  • LoadServerProperties — Manufacturer OtOpcUa, Product OtOpcUa.Server, ProductUri urn:OtOpcUa:Server.

ServerCapabilities

OpcUaApplicationHost populates Server/ServerCapabilities with StandardUA2017, en locale, 100 ms MinSupportedSampleRate, 4 MB message caps, and per-operation limits (1000 per Read/Write/Browse/TranslateBrowsePaths/MonitoredItems/HistoryRead; 0 for MethodCall/NodeManagement/HistoryUpdate).

Certificate handling

Certificate stores default to %LOCALAPPDATA%\OPC Foundation\pki\ (directory-based):

Store Path suffix
Own pki/own
Trusted issuers pki/issuer
Trusted peers pki/trusted
Rejected pki/rejected

Security.AutoAcceptClientCertificates (default true) and RejectSHA1Certificates (default true) are honored. The server certificate is always created — even for None-only deployments — because UserName token encryption needs it.

Key source files

  • src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.csStandardServer subclass + ImpersonateUser hook
  • src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs — per-driver CustomNodeManager2 + dispatch surface
  • src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs — programmatic ApplicationConfiguration + lifecycle
  • src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs — driver registration
  • src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs — Polly pipeline entry point
  • src/ZB.MOM.WW.OtOpcUa.Core/Authorization/ — Phase 6.2 permission trie + evaluator
  • src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs — stack-to-evaluator bridge