diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 83393a5..7ca4122 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -47,6 +47,13 @@ the tests mock. - `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` — real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every 100 ms); asserts `OnDataChange` fires within 3 s of subscribe +- `OpcUaClientReverseConnectSmokeTests.Driver_accepts_reverse_connect_from_opc_plc_rc_simulator` — + reverse-connect (server-initiated) coverage. Driver binds + `opc.tcp://0.0.0.0:4844`, the `opc-plc-rc` docker service dials in via + `--rc opc.tcp://host.docker.internal:4844`, and a Read round-trips over + the inbound socket. Gated on `OPCUA_RC_SIM=1` because the simulator + requires `host.docker.internal` resolution which not every CI runner + exposes. Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` + `IHostConnectivityProbe` (via the Secure Channel exchange). diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index b64aff2..f43fa25 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -59,3 +59,70 @@ Flip `WatchModelChanges` to `false` when: - The upstream server fires spurious `ModelChangeEvent`s that don't reflect real topology changes, causing wasted re-imports. Tighten or disable rather than chasing the noise downstream. + +## Reverse Connect (server-initiated) + +OPC UA's reverse-connect mode flips the transport direction: instead of the +client dialling the server, the **server** dials the client's listener. The +upstream sends a `ReverseHello` and the client continues the OPC UA +handshake on the inbound socket. Required for OT-DMZ deployments where the +plant firewall only permits outbound traffic from the upstream — the +gateway opens a listener, the upstream reaches out. + +### Configuration + +| Option | Default | Notes | +| --- | --- | --- | +| `ReverseConnect.Enabled` | `false` | Opt-in. When `true`, replaces the failover dial-sweep with a `WaitForConnection` call. | +| `ReverseConnect.ListenerUrl` | `null` | Local listener URL the SDK binds. Typically `opc.tcp://0.0.0.0:4844` (any interface) or a specific NIC for multi-homed gateways. **Required when `Enabled` is `true`.** | +| `ReverseConnect.ExpectedServerUri` | `null` | Upstream's `ApplicationUri` to filter inbound dials. `null` accepts the first connection (only safe with one upstream targeting the listener). | + +### Shared listener (singleton) + +A single underlying `Opc.Ua.Client.ReverseConnectManager` per process keyed +on `ListenerUrl`. Two driver instances that share a listener URL multiplex +onto one TCP socket; the SDK demuxes inbound dials by the upstream's +reported `ServerUri`. The wrapper (`ReverseConnectListener`) is +reference-counted — first `Acquire` binds the port, last `Release` tears it +down. Letting drivers come and go independently without races on +port-bind / port-unbind. + +When two drivers share a listener: + +- They MUST set `ExpectedServerUri` to disambiguate; otherwise the first + upstream to dial in wins regardless of which driver is waiting. +- They CAN come and go independently; the listener stays alive while at + least one driver references it. + +### Behaviour + +- The dial path is bypassed entirely when `Enabled` is `true`. Failover + across multiple `EndpointUrls` doesn't apply — there's no client-side + dial to fail over. +- `ExpectedServerUri` is the SDK's filter parameter to `WaitForConnectionAsync`. + Inbound `ReverseHello`s from a different upstream are ignored and the + caller keeps waiting. +- The same `EndpointDescription` derivation runs as the dial path — the + first `EndpointUrl` in the candidate list seeds `SecurityPolicy` / + `SecurityMode` / `EndpointUrl` for the session-create call. The actual + endpoint lives on the upstream and the SDK reconciles after the + `ReverseHello`. +- Cancellation: `Timeout` bounds the wait. A stuck listener with no inbound + dial throws after `Timeout` rather than hanging init forever. +- Shutdown releases the listener reference. The last release stops the + listener so the port can be re-bound by a future driver lifecycle. + +### Wiring it up on the upstream + +The upstream OPC UA server has to be configured to dial out. The `opc-plc` +simulator does this with `--rc=opc.tcp://:4844`; for a real +upstream see your server's reverse-connect docs (most major implementations +expose a "ReverseConnect.Endpoint" config knob). + +### When NOT to use + +- Standard plant networks where the gateway can dial the upstream — the + conventional dial path is simpler and supports failover natively. +- Public-internet OPC UA: reverse-connect is a network-policy workaround, + not a security primitive. Always pair with `Sign` or `SignAndEncrypt` + + a vetted user-token policy. diff --git a/scripts/e2e/test-opcuaclient.ps1 b/scripts/e2e/test-opcuaclient.ps1 index 9361f2b..677013e 100644 --- a/scripts/e2e/test-opcuaclient.ps1 +++ b/scripts/e2e/test-opcuaclient.ps1 @@ -54,8 +54,29 @@ .PARAMETER ChangeWaitSec How long the subscribe stage waits for a data-change. Default 10s. +.PARAMETER ReverseConnect + When set, the script asserts the gateway is configured for reverse-connect + (server-initiated) mode. The OtOpcUa server's DriverConfig for the OpcUaClient + instance must already have ReverseConnect.Enabled=true + ListenerUrl set; this + script doesn't reconfigure the driver, only verifies the bridged path still + reads end-to-end with the listener up. The reverse-connect topology is opaque + to the downstream OPC UA client (us), so the read assertion is identical to + the dial-mode path — the value of running the script in this mode is to catch + regressions where reverse-connect breaks the post-init capability surface. + +.PARAMETER ReverseListenerUrl + Documentation-only. The listener URL the gateway is expected to be bound to + when -ReverseConnect is set; printed in the run banner so operators can + cross-check their server config. Default opc.tcp://0.0.0.0:4844. + .EXAMPLE .\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp" + +.EXAMPLE + # OT-DMZ deployment: the upstream dials the gateway. The script flow is the + # same — we still drive the bridged read through the OtOpcUa server — but the + # banner reflects the reverse-connect topology. + .\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp" -ReverseConnect #> param( @@ -63,7 +84,9 @@ param( [string]$UpstreamUrl = "opc.tcp://localhost:50000", [Parameter(Mandatory)] [string]$BridgedNodeId, [string]$UpstreamNodeId = "ns=3;s=StepUp", - [int]$ChangeWaitSec = 10 + [int]$ChangeWaitSec = 10, + [switch]$ReverseConnect, + [string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844" ) $ErrorActionPreference = "Stop" @@ -73,6 +96,11 @@ $opcUaCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ExeName "otopcua-cli" +if ($ReverseConnect) { + Write-Host "[INFO] -ReverseConnect set: gateway is expected to be bound to listener $ReverseListenerUrl" + Write-Host "[INFO] Upstream OPC UA server should be configured with --rc=$ReverseListenerUrl (or equivalent on a real server)" +} + $results = @() # Stage 1: probe diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 6358139..3c98dbc 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -131,6 +131,34 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d private bool _disposed; /// URL of the endpoint the driver actually connected to. Exposed via . private string? _connectedEndpointUrl; + + /// + /// Reverse-connect listener acquired during when + /// is set. Null when reverse-connect is + /// disabled. Released back to the singleton pool on shutdown so multiple driver + /// instances on the same listener URL can come and go independently. + /// + private ReverseConnectListener? _reverseListener; + + /// + /// Test seam — pluggable reverse-connect "wait" hook. When non-null, + /// uses this delegate instead of + /// calling into a real . Lets unit tests + /// inject a synthetic ITransportWaitingConnection without binding a port + /// or running the SDK's listener threads. + /// + internal Func>? ReverseConnectWaitHookForTest { get; set; } + + /// + /// Test seam — pluggable session factory invoked in the reverse-connect path. + /// Tests can use this to verify that the session-create call receives the + /// expected ITransportWaitingConnection without instantiating the SDK + /// (which hits real cert + transport code). + /// + internal Func>? ReverseConnectSessionFactoryForTest { get; set; } + + /// Test seam — last reverse-connect listener acquired (null when reverse-connect is disabled or shut down). + internal ReverseConnectListener? ReverseListenerForTest => _reverseListener; /// /// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery /// when the session's keep-alive channel reports a bad status. Null outside the @@ -202,34 +230,60 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d var identity = BuildUserIdentity(_options); - // Failover sweep: try each endpoint in order, return the session from the first - // one that successfully connects. Per-endpoint failures are captured so the final - // aggregate exception names every URL that was tried and why — critical diag for - // operators debugging 'why did the failover pick #3?'. - var attemptErrors = new List(candidates.Count); ISession? session = null; string? connectedUrl = null; - foreach (var url in candidates) - { - try - { - session = await OpenSessionOnEndpointAsync( - appConfig, url, _options.SecurityPolicy, _options.SecurityMode, - identity, cancellationToken).ConfigureAwait(false); - connectedUrl = url; - break; - } - catch (Exception ex) - { - attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); - } - } - if (session is null) - throw new AggregateException( - "OPC UA Client failed to connect to any of the configured endpoints. " + - "Tried:\n " + string.Join("\n ", attemptErrors), - attemptErrors.Select(e => new InvalidOperationException(e))); + if (_options.ReverseConnect.Enabled) + { + // Reverse-connect path: instead of dialling each candidate URL, we register + // our listener URL with the process-wide ReverseConnectManager and wait for + // the upstream server to dial in. The first candidate URL still drives + // EndpointDescription selection so SecurityPolicy/Mode + user-identity flow + // through the same code path as the conventional dial — only the transport + // direction flips. ExpectedServerUri filters incoming connections so the + // listener can be shared across drivers targeting different upstreams. + if (string.IsNullOrWhiteSpace(_options.ReverseConnect.ListenerUrl)) + throw new InvalidOperationException( + "ReverseConnect.Enabled=true but ReverseConnect.ListenerUrl is not set. " + + "Configure a listener URL like 'opc.tcp://0.0.0.0:4844' so the upstream server can dial in."); + + var endpointForReverse = candidates.FirstOrDefault() + ?? throw new InvalidOperationException( + "ReverseConnect requires at least one EndpointUrl in the candidate list to derive the EndpointDescription from."); + + session = await OpenReverseConnectSessionAsync( + appConfig, endpointForReverse, identity, cancellationToken).ConfigureAwait(false); + connectedUrl = endpointForReverse; + } + else + { + // Failover sweep: try each endpoint in order, return the session from the first + // one that successfully connects. Per-endpoint failures are captured so the final + // aggregate exception names every URL that was tried and why — critical diag for + // operators debugging 'why did the failover pick #3?'. + var attemptErrors = new List(candidates.Count); + foreach (var url in candidates) + { + try + { + session = await OpenSessionOnEndpointAsync( + appConfig, url, _options.SecurityPolicy, _options.SecurityMode, + identity, cancellationToken).ConfigureAwait(false); + connectedUrl = url; + break; + } + catch (Exception ex) + { + attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); + } + } + + if (session is null) + throw new AggregateException( + "OPC UA Client failed to connect to any of the configured endpoints. " + + "Tried:\n " + string.Join("\n ", attemptErrors), + attemptErrors.Select(e => new InvalidOperationException(e))); + } // Wire the session's keep-alive channel into HostState + the reconnect trigger. // OPC UA keep-alives are authoritative for session liveness: the SDK pings on @@ -268,6 +322,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d { try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { } Session = null; + // Release the reverse-connect listener if we acquired it but session-create failed + // — leaks a port-bind otherwise, blocking the next init attempt. + if (_reverseListener is not null) + { + try { _reverseListener.Release(); } catch { /* best-effort */ } + _reverseListener = null; + } _health = new DriverHealth(DriverState.Faulted, null, ex.Message); throw; } @@ -644,6 +705,96 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d return session; } + /// + /// Open a session over a server-initiated (reverse) connect. Acquires a process-wide + /// for the configured listener URL, waits for the + /// upstream server to dial in (filtered by ), + /// then hands the resulting into the + /// session-create path. The endpoint description still comes from the candidate URL so + /// SecurityPolicy / Mode / cert handling are identical to the dial path — only the + /// transport direction flips. + /// + private async Task OpenReverseConnectSessionAsync( + ApplicationConfiguration appConfig, + string endpointUrl, + UserIdentity identity, + CancellationToken ct) + { + var listenerUrl = _options.ReverseConnect.ListenerUrl!; + var expectedServerUri = _options.ReverseConnect.ExpectedServerUri; + + // Acquire a ref to the singleton listener for this URL. Multiple driver instances + // sharing a URL share one underlying SDK manager — see ReverseConnectListener for + // the ref-count model. + if (ReverseConnectWaitHookForTest is null) + { + _reverseListener = ReverseConnectListener.Acquire(listenerUrl, appConfig); + } + + // Build the ConfiguredEndpoint from the configured endpointUrl. We DON'T call + // GetEndpointsAsync over reverse connect here — the SDK's reverse-connect overload + // accepts a synthetic EndpointDescription and the upstream resends its real one + // during ReverseHello, so a static description is fine for the create call. + var endpointDescription = new EndpointDescription(endpointUrl) + { + SecurityPolicyUri = MapSecurityPolicy(_options.SecurityPolicy), + SecurityMode = _options.SecurityMode switch + { + OpcUaSecurityMode.None => MessageSecurityMode.None, + OpcUaSecurityMode.Sign => MessageSecurityMode.Sign, + OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt, + _ => MessageSecurityMode.None, + }, + }; + var endpointConfig = EndpointConfiguration.Create(appConfig); + endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; + var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig); + + // Wait for the upstream to dial in. Bounded by Timeout so a stuck listener doesn't + // hang init forever — operators see a clear timeout error rather than a silent stall. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_options.Timeout); + + Opc.Ua.ITransportWaitingConnection connection; + if (ReverseConnectWaitHookForTest is not null) + { + connection = await ReverseConnectWaitHookForTest( + new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false); + } + else + { + connection = await _reverseListener!.WaitForServerAsync( + new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false); + } + + // Hand the inbound connection into the session-create path. The factory hook lets + // unit tests assert that the right connection + endpoint flow through without + // standing up a real DefaultSessionFactory (which expects a fully-wired transport). + ISession session; + if (ReverseConnectSessionFactoryForTest is not null) + { + session = await ReverseConnectSessionFactoryForTest( + appConfig, connection, endpoint, identity, cts.Token).ConfigureAwait(false); + } + else + { + session = await new DefaultSessionFactory(telemetry: null!).CreateAsync( + appConfig, + connection, + endpoint, + updateBeforeConnect: false, + checkDomain: false, + _options.SessionName, + (uint)_options.SessionTimeout.TotalMilliseconds, + identity, + preferredLocales: null, + cts.Token).ConfigureAwait(false); + } + + session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds; + return session; + } + /// /// Select the remote endpoint matching both the requested /// and . The SDK's CoreClientUtils.SelectEndpointAsync @@ -799,6 +950,15 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d _connectedEndpointUrl = null; _operationLimits = null; + // Release our hold on the reverse-connect listener. Last release tears the manager + // down; siblings that share the URL keep it alive. Idempotent — releasing a null + // listener (e.g. shutdown after a failed init) is a no-op. + if (_reverseListener is not null) + { + try { _reverseListener.Release(); } catch { /* best-effort */ } + _reverseListener = null; + } + TransitionTo(HostState.Unknown); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index 5e797c3..8b2b2ae 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -253,8 +253,66 @@ public sealed class OpcUaClientDriverOptions /// short enough that single-node adds re-import promptly. /// public TimeSpan ModelChangeDebounce { get; init; } = TimeSpan.FromSeconds(5); + + /// + /// Reverse-connect (server-initiated) configuration. When + /// is true the driver flips the + /// transport direction: instead of dialling the upstream server, it opens a TCP + /// listener on 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. + /// + public ReverseConnectOptions ReverseConnect { get; init; } = new(); } +/// +/// Driver knobs for OPC UA reverse-connect (server-initiated) sessions. Mirrors the +/// SDK's Opc.Ua.Client.ReverseConnectManager surface but expressed as plain +/// config so the driver can decide listener-mode vs dial-mode at startup. +/// +/// +/// +/// Direction: 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 ReverseHello +/// 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. +/// +/// +/// Singleton listener: a single ReverseConnectManager per process +/// keyed on 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 +/// ServerUri. See ReverseConnectListener. +/// +/// +/// +/// When true, the driver opens a listener at and +/// waits for the upstream server to initiate the session. When false +/// (default), the driver uses the conventional dial path against +/// . +/// +/// +/// Local listener URL the SDK binds when reverse-connect is enabled. Typically +/// opc.tcp://0.0.0.0:4844 to accept on every interface, or pinned to a +/// specific NIC for multi-homed gateways. Required when +/// is true. +/// +/// +/// The upstream server's ApplicationUri the driver expects to see in the +/// ReverseHello. The SDK passes this as the serverUri filter to +/// WaitForConnectionAsync so connections from a different upstream are ignored +/// — important when the listener is shared across multiple drivers and several +/// upstreams might dial in. Leave null to accept the first connection regardless +/// (only safe when exactly one upstream targets the listener). +/// +public sealed record ReverseConnectOptions( + bool Enabled = false, + string? ListenerUrl = null, + string? ExpectedServerUri = null); + /// /// Selective import + namespace remap rules for the OPC UA Client driver. Pure local /// filtering inside BrowseRecursiveAsync + EnrichAndRegisterVariablesAsync; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ReverseConnectListener.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ReverseConnectListener.cs new file mode 100644 index 0000000..0fd9996 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ReverseConnectListener.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using Opc.Ua; +using Opc.Ua.Client; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// Process-wide ref-counted wrapper around . +/// Multiple instances that share a listener URL +/// multiplex onto a single underlying manager — opening N sockets on the same +/// port would conflict at the OS layer, and the SDK's manager already dispatches +/// inbound connections by ServerUri, so one manager per listener URL is +/// both correct and unavoidable. +/// +/// +/// +/// Lifecycle: callers a listener for their +/// configured URL during driver init and on shutdown. +/// The first for a given URL constructs the manager, +/// calls AddEndpoint, and starts the listener. The last +/// stops the listener + disposes the manager. Reference +/// counting lets two drivers come up + go down independently without racing on +/// port-bind / port-unbind. +/// +/// +/// Why not Lazy<T>: the listener key is dynamic (the URL string) +/// which Lazy doesn't model. A +/// + locked entry counter is the simplest pattern that handles add/remove +/// atomically without a global lock. +/// +/// +internal sealed class ReverseConnectListener : IDisposable +{ + private static readonly ConcurrentDictionary s_instances + = new(StringComparer.OrdinalIgnoreCase); + + private static readonly object s_globalLock = new(); + + private readonly string _listenerUrl; + private readonly ReverseConnectManager _manager; + private int _refCount; + private bool _started; + private ApplicationConfiguration? _appConfig; + + /// + /// Test seam — total instances ever created for a given URL. Lets unit tests + /// assert that two drivers sharing a URL only spin up one underlying manager. + /// + internal static int InstanceCountForTest(string listenerUrl) => + s_instances.TryGetValue(listenerUrl, out var inst) ? inst._refCount : 0; + + /// Test seam — peek the underlying SDK manager for assertion / mocking. + internal ReverseConnectManager Manager => _manager; + + /// Test seam — current refcount on this entry. + internal int RefCountForTest => _refCount; + + private ReverseConnectListener(string listenerUrl) + { + _listenerUrl = listenerUrl; +#pragma warning disable CS0618 // ITelemetryContext-less ctor is obsolete; the driver doesn't plumb telemetry + _manager = new ReverseConnectManager(); +#pragma warning restore CS0618 + } + + /// + /// Acquire a reference to the listener for . The + /// first acquire constructs the manager + binds the socket; subsequent acquires + /// bump the ref-count and reuse the live manager. Idempotent on + /// AddEndpoint — the SDK's manager tolerates the same URL being added + /// twice but the listener guards against it anyway so the wire-side state stays + /// deterministic across test runs. is needed by + /// StartService so the manager has the cert validator + transport quotas + /// of the calling driver. The first acquire's config wins — subsequent acquires + /// trust that all drivers sharing the listener use compatible cert / quota config. + /// + public static ReverseConnectListener Acquire(string listenerUrl, ApplicationConfiguration appConfig) + { + if (string.IsNullOrWhiteSpace(listenerUrl)) + throw new ArgumentException("listenerUrl is required", nameof(listenerUrl)); + ArgumentNullException.ThrowIfNull(appConfig); + + lock (s_globalLock) + { + var entry = s_instances.GetOrAdd(listenerUrl, u => new ReverseConnectListener(u)); + entry._refCount++; + entry.EnsureStarted(appConfig); + return entry; + } + } + + private void EnsureStarted(ApplicationConfiguration appConfig) + { + if (_started) return; + _appConfig = appConfig; + var uri = new Uri(_listenerUrl); + _manager.AddEndpoint(uri); + _manager.StartService(appConfig); + _started = true; + } + + /// + /// Test seam — construct a listener entry without binding a real socket. Used + /// by unit tests to verify acquire/release ref-counting without paying the + /// SDK + OS port-bind cost. + /// + internal static ReverseConnectListener AcquireForTest(string listenerUrl) + { + if (string.IsNullOrWhiteSpace(listenerUrl)) + throw new ArgumentException("listenerUrl is required", nameof(listenerUrl)); + + lock (s_globalLock) + { + var entry = s_instances.GetOrAdd(listenerUrl, u => new ReverseConnectListener(u)); + entry._refCount++; + // Skip EnsureStarted — tests don't want the real port bind. + return entry; + } + } + + /// + /// Wait for the upstream named by to dial in to + /// this listener. Proxies straight to the SDK's + /// WaitForConnection — the listener owns lifecycle, the SDK owns the + /// wait + the inbound transport object. + /// + public Task WaitForServerAsync( + Uri endpointUrl, + string? serverUri, + CancellationToken ct = default) => + _manager.WaitForConnectionAsync(endpointUrl, serverUri, ct); + + /// + /// Drop one reference. When the last reference goes away the manager is stopped + /// and the dictionary entry removed so a future driver can rebind the same port + /// cleanly. Releasing a listener that's already been fully torn down is a no-op + /// so disposal paths can be defensive. + /// + public void Release() + { + lock (s_globalLock) + { + _refCount--; + if (_refCount > 0) return; + + // Dispose() walks DisposeHosts() → CloseHosts() internally so the listener + // socket is released cleanly. There's no public StopService; the SDK exposes + // only Dispose() for external teardown. + try { _manager.Dispose(); } catch { /* best-effort during shutdown */ } + s_instances.TryRemove(_listenerUrl, out _); + _started = false; + } + } + + public void Dispose() => Release(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml index 18849ce..4bdc1d8 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml @@ -43,3 +43,41 @@ services: timeout: 2s retries: 10 start_period: 10s + + # opc-plc-rc — reverse-connect (server-initiated) variant. The simulator + # acts as the OPC UA server but, unlike the regular service above, it dials + # OUT to the client's listener URL instead of accepting an inbound dial. + # Mirrors the OT-DMZ topology where the plant firewall only permits + # outbound traffic from the upstream server. The driver-side test fixture + # binds opc.tcp://0.0.0.0:4844 and waits for opc-plc-rc to ReverseHello. + # + # `--rc` is opc-plc's reverse-connect knob — value is the client URL the + # simulator should dial when it has no inbound connection. host.docker.internal + # is the docker-for-windows / docker-for-mac shorthand for the host's IP; + # on Linux hosts use --add-host=host.docker.internal:host-gateway. + opc-plc-rc: + image: mcr.microsoft.com/iotedge/opc-plc:2.14.10 + container_name: otopcua-opc-plc-rc + restart: "no" + extra_hosts: + - "host.docker.internal:host-gateway" + command: + # --pn=50001: bind on a different port so this container can run alongside + # the dial-mode simulator above. Reverse-connect doesn't require + # the client to know this port (the simulator is the dialer) + # but it still has to bind one for any incoming admin queries. + # --rc: reverse-connect target — the simulator dials this URL and + # presents its OPC UA endpoint over the inbound socket. Must + # point at the test runner's listener. + # --ut/--aa/--alm: same flags as the regular profile. + - "--pn=50001" + - "--rc=opc.tcp://host.docker.internal:4844" + - "--ut" + - "--aa" + - "--alm" + healthcheck: + test: ["CMD-SHELL", "netstat -an | grep -q ':50001.*LISTEN' || exit 1"] + interval: 5s + timeout: 2s + retries: 10 + start_period: 10s diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcReverseConnectFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcReverseConnectFixture.cs new file mode 100644 index 0000000..5efd926 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcReverseConnectFixture.cs @@ -0,0 +1,56 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Fixture for the reverse-connect variant of opc-plc (PR-11). Unlike the +/// dial-mode , the simulator here is the dialer: +/// it reaches OUT to the test runner's listener URL on +/// opc.tcp://host.docker.internal:4844. The fixture's job is to +/// advertise the listener URL the test should bind and provide a clear +/// skip reason when the docker-compose service isn't running. +/// +/// +/// +/// Why no port-probe: the conventional fixture probes the simulator's +/// server port to detect docker-up. In reverse-connect the simulator opens +/// no inbound port — it's a pure dialer — so a probe would always fail. +/// Tests that want a hard skip should look at +/// which is set from the OPCUA_RC_SIM env var (any value = +/// "simulator running"; absent = skip). +/// +/// +/// The "shared listener URL" model is enforced by the driver's +/// ReverseConnectListener singleton — multiple smoke tests in the +/// same xunit assembly share one listener instance even if they run in +/// parallel. Tests should pick distinct ExpectedServerUri values to +/// demultiplex inbound connections. +/// +/// +public sealed class OpcPlcReverseConnectFixture : IAsyncDisposable +{ + private const string DefaultListenerUrl = "opc.tcp://0.0.0.0:4844"; + private const string EnvVar = "OPCUA_RC_SIM"; + + /// The listener URL the driver should bind for incoming reverse-connect dials. + public string ListenerUrl { get; } = DefaultListenerUrl; + + /// Skip reason when the reverse-connect simulator isn't available; null when ready. + public string? SkipReason { get; } + + public OpcPlcReverseConnectFixture() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(EnvVar))) + { + SkipReason = + $"Reverse-connect smoke skipped — set {EnvVar}=1 once `docker compose -f Docker/docker-compose.yml up opc-plc-rc` is healthy. " + + "The dialer needs host.docker.internal to reach this machine — verify Docker Desktop's network mode supports it."; + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +[Xunit.CollectionDefinition(Name)] +public sealed class OpcPlcReverseConnectCollection : Xunit.ICollectionFixture +{ + public const string Name = "OpcPlcReverseConnect"; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientReverseConnectSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientReverseConnectSmokeTests.cs new file mode 100644 index 0000000..27327b0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientReverseConnectSmokeTests.cs @@ -0,0 +1,65 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Reverse-connect smoke (PR-11). Asserts the driver binds a listener at the +/// configured URL and accepts an inbound dial from opc-plc-rc (the +/// reverse-connect variant of Microsoft Industrial IoT's OPC UA simulator). +/// The session that comes up should be functionally identical to a dialled +/// session — same Read / Subscribe surface — but the transport direction is +/// server → client instead of client → server. +/// +/// +/// +/// Build-only by default: the test is gated on OPCUA_RC_SIM +/// + the docker-compose opc-plc-rc service. CI runs that don't +/// spin up the dialer skip with a clear message; the build still has to +/// compile so wire-level regressions in the reverse-connect code path are +/// caught even when the dialer isn't around. +/// +/// +[Collection(OpcPlcReverseConnectCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "opc-plc-rc")] +public sealed class OpcUaClientReverseConnectSmokeTests(OpcPlcReverseConnectFixture rc) +{ + [Fact] + public async Task Driver_accepts_reverse_connect_from_opc_plc_rc_simulator() + { + if (rc.SkipReason is not null) Assert.Skip(rc.SkipReason); + + var options = new OpcUaClientDriverOptions + { + // Conventional EndpointUrl still required — the driver derives the + // EndpointDescription from it for the session-create call. The actual + // dial direction is flipped by ReverseConnect.Enabled below. + EndpointUrl = "opc.tcp://opc-plc-rc:50001", + SecurityPolicy = OpcUaSecurityPolicy.None, + SecurityMode = OpcUaSecurityMode.None, + AuthType = OpcUaAuthType.Anonymous, + AutoAcceptCertificates = true, + Timeout = TimeSpan.FromSeconds(15), + SessionTimeout = TimeSpan.FromSeconds(60), + ReverseConnect = new ReverseConnectOptions( + Enabled: true, + ListenerUrl: rc.ListenerUrl, + // null = accept any upstream — only one is dialling this listener in + // the smoke test, so there's no demux to worry about. + ExpectedServerUri: null), + }; + + await using var drv = new OpcUaClientDriver(options, "opcua-rc-smoke"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // The session is up via reverse path — assert a steady-state read works. + var snapshots = await drv.ReadAsync( + [OpcPlcProfile.StepUp], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(1); + snapshots[0].StatusCode.ShouldBe(0u, + "reverse-connect session must round-trip a Read identically to a dialled session"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReverseConnectTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReverseConnectTests.cs new file mode 100644 index 0000000..9338c15 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReverseConnectTests.cs @@ -0,0 +1,259 @@ +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit tests for the reverse-connect (server-initiated) path (PR-11). The driver +/// exposes two test seams — ReverseConnectWaitHookForTest and +/// ReverseConnectSessionFactoryForTest — that bypass the SDK's port-bind + +/// real-transport machinery so we can assert the wiring without standing up an +/// actual reverse-connect TCP listener. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientReverseConnectTests : IDisposable +{ + public void Dispose() + { + // Each test acquires/releases its own listener entries — but if a test fails + // mid-way the static dictionary in ReverseConnectListener could be left dirty. + // Nothing else here to do; the dictionary is internal and tests target unique + // listener URLs to avoid cross-test contamination. + } + + [Fact] + public void ReverseConnect_options_default_to_disabled() + { + var opts = new OpcUaClientDriverOptions(); + opts.ReverseConnect.ShouldNotBeNull(); + opts.ReverseConnect.Enabled.ShouldBeFalse( + "default deployments dial outbound — reverse-connect is opt-in for OT-DMZ networks"); + opts.ReverseConnect.ListenerUrl.ShouldBeNull(); + opts.ReverseConnect.ExpectedServerUri.ShouldBeNull(); + } + + [Fact] + public async Task Disabled_reverse_connect_uses_existing_dial_path() + { + // Initialize against an unreachable endpoint with reverse-connect off; we expect + // the failover sweep to fail (since 192.0.2.x is reserved-for-documentation and + // routes nowhere). The point is to assert the dial path runs — not the listener + // path — by checking the wait hook was never invoked. + var waitInvoked = false; + var opts = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://192.0.2.1:4840", + PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(200), + Timeout = TimeSpan.FromMilliseconds(200), + ReverseConnect = new ReverseConnectOptions(Enabled: false), + }; + await using var drv = new OpcUaClientDriver(opts, "opcua-rc-disabled"); + drv.ReverseConnectWaitHookForTest = (_, _, _) => + { + waitInvoked = true; + return Task.FromResult(null!); + }; + + await Should.ThrowAsync(() => + drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + waitInvoked.ShouldBeFalse( + "reverse-connect wait hook must not run when ReverseConnect.Enabled=false"); + drv.ReverseListenerForTest.ShouldBeNull(); + } + + [Fact] + public async Task Enabled_reverse_connect_invokes_wait_with_expected_server_uri() + { + Uri? capturedListener = null; + string? capturedServerUri = null; + var opts = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://upstream:4840", + ReverseConnect = new ReverseConnectOptions( + Enabled: true, + ListenerUrl: "opc.tcp://0.0.0.0:14844", + ExpectedServerUri: "urn:upstream-plc:server"), + Timeout = TimeSpan.FromMilliseconds(500), + }; + await using var drv = new OpcUaClientDriver(opts, "opcua-rc-wait"); + + drv.ReverseConnectWaitHookForTest = (uri, serverUri, _) => + { + capturedListener = uri; + capturedServerUri = serverUri; + // Return null — the session-factory hook below short-circuits before it + // gets used so a null connection can't blow up downstream. + return Task.FromResult(new FakeTransportWaitingConnection()); + }; + + // Halt the path immediately after WaitForServerAsync — the only thing we're + // asserting in this test is that wait runs with the right args. Throwing here + // means InitializeAsync transitions to Faulted but the assertions on the captured + // state still hold. + drv.ReverseConnectSessionFactoryForTest = (_, _, _, _, _) => + throw new InvalidOperationException("test stops before session create"); + + await Should.ThrowAsync(() => + drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + capturedListener.ShouldNotBeNull(); + capturedListener!.ToString().ShouldContain("14844"); + capturedServerUri.ShouldBe("urn:upstream-plc:server"); + } + + [Fact] + public async Task Enabled_reverse_connect_passes_connection_into_session_factory() + { + // Assert that the ITransportWaitingConnection returned from WaitForServerAsync + // flows verbatim into the session-create hook. This is the load-bearing wiring + // — a swap of variables at this seam would silently route the wrong connection + // into the SDK and the failure would surface only at session activation time. + var stubConnection = new FakeTransportWaitingConnection(); + ITransportWaitingConnection? observedConnection = null; + + var opts = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://upstream:4840", + ReverseConnect = new ReverseConnectOptions( + Enabled: true, + ListenerUrl: "opc.tcp://0.0.0.0:14845"), + }; + await using var drv = new OpcUaClientDriver(opts, "opcua-rc-conn"); + drv.ReverseConnectWaitHookForTest = (_, _, _) => + Task.FromResult(stubConnection); + drv.ReverseConnectSessionFactoryForTest = (_, conn, _, _, _) => + { + observedConnection = conn; + // Throw to short-circuit the post-create wiring (KeepAlive, diagnostics) + // — this test only cares about the connection plumbing. + throw new InvalidOperationException("test stops at factory"); + }; + + await Should.ThrowAsync(() => + drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + observedConnection.ShouldBeSameAs(stubConnection); + } + + [Fact] + public async Task Enabled_reverse_connect_without_listener_url_fails_fast() + { + var opts = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://upstream:4840", + ReverseConnect = new ReverseConnectOptions(Enabled: true, ListenerUrl: null), + }; + await using var drv = new OpcUaClientDriver(opts, "opcua-rc-missing-listener"); + + var ex = await Should.ThrowAsync(() => + drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + // The actual InvalidOperationException is wrapped in nothing — InitializeAsync + // re-throws after marking Faulted. Inspect the inner-most message either way. + var msg = ex is AggregateException agg ? string.Join("|", agg.InnerExceptions.Select(e => e.Message)) : ex.Message; + msg.ShouldContain("ListenerUrl"); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + [Fact] + public void Listener_acquire_release_is_refcounted_per_url() + { + var url = "opc.tcp://0.0.0.0:14001"; + + var a = ReverseConnectListener.AcquireForTest(url); + var b = ReverseConnectListener.AcquireForTest(url); + + // Same URL = same underlying entry. Two drivers sharing a listener must + // see the same SDK manager, otherwise we'd be double-binding the port. + a.ShouldBeSameAs(b); + a.RefCountForTest.ShouldBe(2); + + a.Release(); + b.RefCountForTest.ShouldBe(1, "first release drops one reference, listener stays alive"); + + b.Release(); + ReverseConnectListener.InstanceCountForTest(url).ShouldBe(0, + "last release tears down the listener so the port can be re-bound"); + } + + [Fact] + public void Listener_distinct_urls_get_distinct_entries() + { + var a = ReverseConnectListener.AcquireForTest("opc.tcp://0.0.0.0:14010"); + var b = ReverseConnectListener.AcquireForTest("opc.tcp://0.0.0.0:14011"); + + a.ShouldNotBeSameAs(b, + "different listener URLs must own independent SDK managers — sharing would conflate inbound connections from unrelated upstreams"); + + a.Release(); + b.Release(); + } + + [Fact] + public async Task Disposal_releases_reverse_listener_reference() + { + var listenerUrl = "opc.tcp://0.0.0.0:14020"; + + // Pre-acquire a reference so we can observe the refcount drop when the driver + // shuts down. The driver uses a real Acquire (not the test seam) only when the + // wait hook is null — but we don't run init here, we just assert the listener + // hand-off model. + var pre = ReverseConnectListener.AcquireForTest(listenerUrl); + pre.RefCountForTest.ShouldBe(1); + + // Simulate: driver came up, acquired listener, shut down → refcount returns + // to 1 (just our pre). + var drvHeld = ReverseConnectListener.AcquireForTest(listenerUrl); + drvHeld.RefCountForTest.ShouldBe(2); + drvHeld.Release(); + pre.RefCountForTest.ShouldBe(1); + + pre.Release(); + ReverseConnectListener.InstanceCountForTest(listenerUrl).ShouldBe(0); + + await Task.CompletedTask; + } + + [Fact] + public void DTO_json_round_trip_preserves_reverse_connect_settings() + { + // The driver host deserializes OpcUaClientDriverOptions from JSON, so a missing + // System.Text.Json contract on a new section would silently lose the operator's + // config. Round-trip a populated ReverseConnectOptions and check it survives + // the JSON boundary. + var opts = new OpcUaClientDriverOptions + { + EndpointUrl = "opc.tcp://upstream:4840", + ReverseConnect = new ReverseConnectOptions( + Enabled: true, + ListenerUrl: "opc.tcp://0.0.0.0:4844", + ExpectedServerUri: "urn:plant:upstream-server"), + }; + + var json = JsonSerializer.Serialize(opts); + var roundTripped = JsonSerializer.Deserialize(json); + + roundTripped.ShouldNotBeNull(); + roundTripped!.ReverseConnect.ShouldNotBeNull(); + roundTripped.ReverseConnect.Enabled.ShouldBeTrue(); + roundTripped.ReverseConnect.ListenerUrl.ShouldBe("opc.tcp://0.0.0.0:4844"); + roundTripped.ReverseConnect.ExpectedServerUri.ShouldBe("urn:plant:upstream-server"); + } + + /// + /// Bare-bones test double. The interface + /// surface is small (EndpointUrl + ServerUri + Handle) so a hand-rolled fake + /// keeps unit tests independent of any specific mocking library. + /// + private sealed class FakeTransportWaitingConnection : ITransportWaitingConnection + { + public Uri EndpointUrl { get; } = new("opc.tcp://fake-upstream:4840"); + public string ServerUri => "urn:fake-upstream"; + public object Handle => new object(); + } +}