Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
2026-04-26 06:08:30 -04:00

485 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// OPC UA Client (gateway) driver configuration. Bound from <c>DriverConfig</c> JSON at
/// driver-host registration time. Models the settings documented in
/// <c>docs/v2/driver-specs.md</c> §8.
/// </summary>
/// <remarks>
/// This driver connects to a REMOTE OPC UA server and re-exposes its address space
/// through the local OtOpcUa server — the opposite direction from the usual "server
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
/// protections cover it.
/// </remarks>
public sealed class OpcUaClientDriverOptions
{
/// <summary>
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
/// shortcut for a single-endpoint deployment — equivalent to setting
/// <see cref="EndpointUrls"/> to a list with this one URL. When both are provided,
/// the list wins and <see cref="EndpointUrl"/> is ignored.
/// </summary>
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
/// <summary>
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
/// order at <see cref="OpcUaClientDriver.InitializeAsync"/> and on session drop;
/// the first URL that successfully connects wins. Typical use-case: an OPC UA server
/// pair running in hot-standby (primary 4840 + backup 4841) where either can serve
/// the same address space. Leave unset (or empty) to use <see cref="EndpointUrl"/>
/// as a single-URL shortcut.
/// </summary>
public IReadOnlyList<string> EndpointUrls { get; init; } = [];
/// <summary>
/// Per-endpoint connect-attempt timeout during the failover sweep. Short enough that
/// cycling through several dead servers doesn't blow the overall init budget, long
/// enough to tolerate a slow TLS handshake on a healthy server. Applied independently
/// of <see cref="Timeout"/> which governs steady-state operations.
/// </summary>
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
/// <summary>
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
/// and prepends the discovered endpoint URLs to the failover candidate list. When
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
/// a fallback), the discovered URLs replace the candidate list entirely so a
/// discovery-driven deployment can be configured without specifying any endpoints
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
/// statically configured candidates.
/// </summary>
/// <remarks>
/// <para>
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
/// session is bound to the configured policy.
/// </para>
/// <para>
/// Endpoints returned by discovery are filtered to those matching
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
/// the candidate list, so a discovery sweep against a multi-policy server only
/// surfaces endpoints the driver could actually connect to.
/// </para>
/// </remarks>
public string? DiscoveryUrl { get; init; }
/// <summary>
/// Security policy to require when selecting an endpoint. Either a
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
/// forward-compatibility with future OPC UA policies not yet in the enum).
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
/// connects to the first endpoint whose policy name matches AND whose mode matches
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
/// the driver picks any unsecured endpoint regardless of policy string.
/// </summary>
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
/// <summary>Security mode.</summary>
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
/// <summary>Authentication type.</summary>
public OpcUaAuthType AuthType { get; init; } = OpcUaAuthType.Anonymous;
/// <summary>User name (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
public string? Username { get; init; }
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
public string? Password { get; init; }
/// <summary>
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
/// loads the cert + private key, which the remote server validates against its
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
/// Leave unset to use the driver's application-instance certificate as the user
/// token (not typical — most deployments have a separate user cert).
/// </summary>
public string? UserCertificatePath { get; init; }
/// <summary>
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
/// protected. PEM files generally have their password on the adjacent key file; this
/// knob only applies to password-locked PFX.
/// </summary>
public string? UserCertificatePassword { get; init; }
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
/// <summary>Client-side keep-alive interval.</summary>
public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Initial reconnect delay after a session drop.</summary>
public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// When <c>true</c>, the driver accepts any self-signed / untrusted server certificate.
/// Dev-only — must be <c>false</c> in production so MITM attacks against the opc.tcp
/// channel fail closed.
/// </summary>
public bool AutoAcceptCertificates { get; init; } = false;
/// <summary>
/// Application URI the driver reports during session creation. Must match the
/// subject-alt-name on the client certificate if one is used, which is why it's a
/// config knob rather than hard-coded.
/// </summary>
public string ApplicationUri { get; init; } = "urn:localhost:OtOpcUa:GatewayClient";
/// <summary>
/// Friendly name sent to the remote server for diagnostics. Shows up in the remote
/// server's session-list so operators can identify which gateway instance is calling.
/// </summary>
public string SessionName { get; init; } = "OtOpcUa-Gateway";
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (i=85). Set to
/// a scoped root to restrict the address space the driver exposes locally — useful
/// when the remote server has tens of thousands of nodes and only a subset is
/// needed downstream.
/// </summary>
public string? BrowseRoot { get; init; }
/// <summary>
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. Default 10_000 —
/// bounds memory on runaway remote servers without being so low that normal
/// deployments hit it. When the cap is reached discovery stops and a warning is
/// written to the driver health surface; the partially-discovered tree is still
/// projected into the local address space.
/// </summary>
public int MaxDiscoveredNodes { get; init; } = 10_000;
/// <summary>
/// Max hierarchical depth of the browse. Default 10 — deep enough for realistic
/// OPC UA information models, shallow enough that cyclic graphs can't spin the
/// browse forever.
/// </summary>
public int MaxBrowseDepth { get; init; } = 10;
/// <summary>
/// Per-subscription tuning knobs applied when the driver creates data + alarm
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
/// values so existing deployments see no behaviour change.
/// </summary>
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
/// <summary>
/// Server-certificate validation knobs applied during the
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
/// handling for revoked certs (always rejected, never auto-accepted), unknown
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
/// existing behaviour wherever possible — the one tightening is
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
/// since SHA-1 is spec-deprecated for OPC UA.
/// </summary>
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
/// <summary>
/// Curation rules applied to the upstream address space during
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
/// downstream clients actually need, rename namespace URIs so the local-side metadata
/// stays consistent across upstream-server swaps, and override the default
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
/// pre-curation behaviour exactly — empty include = include all.
/// </summary>
public OpcUaClientCurationOptions Curation { get; init; } = new();
/// <summary>
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
/// discovered type-definition nodes into the local address space via
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
/// when downstream clients need the upstream type system to render structured values or
/// decode custom event fields.
/// </summary>
/// <remarks>
/// <para>
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
/// batched browse per root rather than per-node round trips). This PR ships the
/// <i>structural</i> mirror only — every type node is registered with its identity,
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
/// NOT primed. (The OPCFoundation SDK removed
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
/// that need structured-type decoding can still consume
/// <c>Variant&lt;ExtensionObject&gt;</c> on the wire.
/// </para>
/// <para>
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
/// walk; paths are slash-joined under their root (e.g.
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
/// empty include = include all is the right default.
/// </para>
/// </remarks>
public bool MirrorTypeDefinitions { get; init; } = false;
/// <summary>
/// When <c>true</c> (default), the driver subscribes to
/// <c>BaseModelChangeEventType</c> + <c>GeneralModelChangeEventType</c> on the
/// upstream <c>Server</c> node (<c>i=2253</c>) at the end of <see cref="OpcUaClientDriver.InitializeAsync"/>.
/// When the upstream advertises a topology change, the driver coalesces events over
/// <see cref="ModelChangeDebounce"/> and triggers a re-import (equivalent to calling
/// <c>ReinitializeAsync</c>) so the locally-mirrored address space tracks the upstream.
/// </summary>
/// <remarks>
/// <para>
/// The re-import path acquires the same <c>_gate</c> that read / write / browse /
/// subscribe paths use, which means there's a brief browse-gap (≈ the upstream
/// <c>DiscoverAsync</c> duration) during which downstream calls block on the
/// driver's gate. Operators can disable the watch when the upstream topology is
/// known-static and the gap isn't acceptable.
/// </para>
/// </remarks>
public bool WatchModelChanges { get; init; } = true;
/// <summary>
/// Coalescing window for upstream <c>ModelChangeEvent</c> notifications. The first
/// event in a window starts the timer; further events extend it; when the timer
/// fires the driver runs one re-import regardless of how many events arrived. Default
/// 5 seconds — long enough to absorb a bulk topology edit on the upstream server,
/// short enough that single-node adds re-import promptly.
/// </summary>
public TimeSpan ModelChangeDebounce { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Reverse-connect (server-initiated) configuration. When
/// <see cref="ReverseConnectOptions.Enabled"/> is <c>true</c> the driver flips the
/// transport direction: instead of dialling the upstream server, it opens a TCP
/// listener on <see cref="ReverseConnectOptions.ListenerUrl"/> and waits for the
/// upstream server to initiate the connection ("ReverseHello"). Required for
/// OT-DMZ deployments where the firewall only permits outbound traffic from the
/// plant network — the upstream server reaches out, the gateway listens.
/// </summary>
public ReverseConnectOptions ReverseConnect { get; init; } = new();
}
/// <summary>
/// Driver knobs for OPC UA reverse-connect (server-initiated) sessions. Mirrors the
/// SDK's <c>Opc.Ua.Client.ReverseConnectManager</c> surface but expressed as plain
/// config so the driver can decide listener-mode vs dial-mode at startup.
/// </summary>
/// <remarks>
/// <para>
/// <b>Direction</b>: in conventional OPC UA the client opens the TCP connection
/// to the server. Reverse-connect inverts this — the server initiates a TCP
/// connection to a listener the client exposes, then sends a <c>ReverseHello</c>
/// message naming itself; the client picks the right session config and continues
/// the OPC UA handshake on the inbound socket. Critical for OT-DMZ networks where
/// the plant firewall only allows outbound traffic.
/// </para>
/// <para>
/// <b>Singleton listener</b>: a single <c>ReverseConnectManager</c> per process
/// keyed on <see cref="ListenerUrl"/> multiplexes inbound connections across
/// driver instances. Two drivers configured with the same listener URL share one
/// underlying TCP socket; the manager dispatches by the upstream's reported
/// <c>ServerUri</c>. See <c>ReverseConnectListener</c>.
/// </para>
/// </remarks>
/// <param name="Enabled">
/// When <c>true</c>, the driver opens a listener at <see cref="ListenerUrl"/> and
/// waits for the upstream server to initiate the session. When <c>false</c>
/// (default), the driver uses the conventional dial path against
/// <see cref="OpcUaClientDriverOptions.EndpointUrls"/>.
/// </param>
/// <param name="ListenerUrl">
/// Local listener URL the SDK binds when reverse-connect is enabled. Typically
/// <c>opc.tcp://0.0.0.0:4844</c> to accept on every interface, or pinned to a
/// specific NIC for multi-homed gateways. Required when
/// <see cref="Enabled"/> is <c>true</c>.
/// </param>
/// <param name="ExpectedServerUri">
/// The upstream server's <c>ApplicationUri</c> the driver expects to see in the
/// <c>ReverseHello</c>. The SDK passes this as the <c>serverUri</c> filter to
/// <c>WaitForConnectionAsync</c> so connections from a different upstream are ignored
/// — important when the listener is shared across multiple drivers and several
/// upstreams might dial in. Leave <c>null</c> to accept the first connection regardless
/// (only safe when exactly one upstream targets the listener).
/// </param>
public sealed record ReverseConnectOptions(
bool Enabled = false,
string? ListenerUrl = null,
string? ExpectedServerUri = null);
/// <summary>
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
/// no new SDK calls.
/// </summary>
/// <remarks>
/// <para>
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
/// + behaviour stay simple.
/// </para>
/// <para>
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
/// browsed, which keeps the wire cost down on large servers.
/// </para>
/// </remarks>
/// <param name="IncludePaths">
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
/// (default — preserves pre-curation behaviour).
/// </param>
/// <param name="ExcludePaths">
/// Glob patterns matched against the BrowsePath segment list. Wins over
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
/// except <c>Plant/Diagnostics</c>" rules.
/// </param>
/// <param name="NamespaceRemap">
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
/// exactly. Defaults to empty (no remap).
/// </param>
/// <param name="RootAlias">
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
/// </param>
public sealed record OpcUaClientCurationOptions(
IReadOnlyList<string>? IncludePaths = null,
IReadOnlyList<string>? ExcludePaths = null,
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
string? RootAlias = null);
/// <summary>
/// Knobs governing the server-certificate validation callback. Plumbed onto
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
/// options to keep cert-related config grouped together.
/// </summary>
/// <remarks>
/// <para>
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
/// required. When the directory is absent or empty, the SDK reports
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
/// <see cref="RejectUnknownRevocationStatus"/>.
/// </para>
/// </remarks>
/// <param name="RejectSHA1SignedCertificates">
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
/// <c>false</c> only for short-term interop with legacy controllers.
/// </param>
/// <param name="RejectUnknownRevocationStatus">
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
/// </param>
/// <param name="MinimumCertificateKeySize">
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
/// deployments. Non-RSA keys (ECC) bypass this check.
/// </param>
public sealed record OpcUaCertificateValidationOptions(
bool RejectSHA1SignedCertificates = true,
bool RejectUnknownRevocationStatus = false,
int MinimumCertificateKeySize = 2048);
/// <summary>
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
/// </summary>
/// <param name="KeepAliveCount">
/// Number of consecutive empty publish cycles before the server sends a keep-alive
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
/// client notices a stalled subscription within ~5x the publish interval.
/// </param>
/// <param name="LifetimeCount">
/// Number of consecutive missed publish responses before the server tears down the
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
/// </param>
/// <param name="MaxNotificationsPerPublish">
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
/// </param>
/// <param name="Priority">
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
/// </param>
/// <param name="MinPublishingIntervalMs">
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
/// will only round up anyway. Default 50ms.
/// </param>
/// <param name="AlarmsPriority">
/// Subscription priority for the alarm subscription (0..255). Higher than
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
/// data-tag bursts.
/// </param>
public sealed record OpcUaSubscriptionDefaults(
int KeepAliveCount = 10,
uint LifetimeCount = 1000,
uint MaxNotificationsPerPublish = 0,
byte Priority = 0,
int MinPublishingIntervalMs = 50,
byte AlarmsPriority = 1);
/// <summary>OPC UA message security mode.</summary>
public enum OpcUaSecurityMode
{
None,
Sign,
SignAndEncrypt,
}
/// <summary>
/// OPC UA security policies recognized by the driver. Maps to the standard
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
/// endpoint matching.
/// </summary>
/// <remarks>
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
/// </remarks>
public enum OpcUaSecurityPolicy
{
/// <summary>No security. Unsigned, unencrypted wire.</summary>
None,
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
Basic128Rsa15,
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
Basic256,
/// <summary>Recommended baseline for current deployments.</summary>
Basic256Sha256,
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
Aes128_Sha256_RsaOaep,
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
Aes256_Sha256_RsaPss,
}
/// <summary>User authentication type sent to the remote server.</summary>
public enum OpcUaAuthType
{
Anonymous,
Username,
Certificate,
}