mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
87c35b84b6
## Summary
Fixes the findings of a bug audit of `Opc.Ua.Client` and
`Opc.Ua.Client.ComplexTypes`, done on master plus #4516 (now merged).
- **Audit:** 82 findings across 12 areas, each checked against the OPC
UA specification. Final severities: 2 High, 37 Medium, 43 Low.
- **Fixes:** 76 fixed, 4 partial, 2 skipped (see [Not
fixed](#not-fixed--partial)).
- **Review:** a follow-up review of the fixes found 15 more defects in
them, all fixed here.
- **Tests:** every fix has a regression test, except a few races that
cannot be reproduced deterministically; the commit messages say which.
The finding IDs (`L<area>-<n>` for the audit, `G<n>` for the review)
appear in the commit titles.
## Behaviour and user-facing changes
### Security and endpoint selection
- **Reconnect no longer drops to weaker security (L3-1, High).** When
the server rejects the configured security policy or mode during
reconnect, `SessionReconnectHandler` no longer switches to whatever
endpoint the server advertises, None included. It only picks an endpoint
at least as strong as the configured one, judged by a locally computed
security level. The server-advertised `SecurityLevel` is not trusted. If
nothing qualifies, reconnect fails with the original error (new log
event `SessionReconnectHandler + 13`) and keeps retrying with the
original settings.
- **`CoreClientUtils.SelectEndpoint(useSecurity: true)` never returns a
SecurityMode None endpoint over opc.tcp (L9-9, High).** It now returns
`null` when no secure endpoint matches.
- When the discovery URL is `https`/`opc.https`, an HTTPS endpoint with
SecurityMode None is still accepted as a last resort, because TLS
protects it (Part 6 §7.4.1) (G17).
- Endpoints with SecurityMode `Invalid` no longer count as secure.
- `GlobalDiscoveryServerClient` and `ServerPushConfigurationClient` now
throw `BadSecurityPolicyRejected` when no secure endpoint matches,
instead of passing `null` on. The ConsoleReferenceClient sample reports
this with a clear error.
- **No Bearer/Basic credentials over `http://` (L12-2).**
`WebApiClient.Create` and WebApi channel open/reconnect reject them with
`BadSecurityChecksFailed`. The public `WebApiClient(HttpClient, …)`
constructor also rejects them when the client's `BaseAddress` is not
https, and every send checks the address it actually goes to (covers a
`BaseAddress` set later).
### Session, keep-alive and identity
- **Faster detection of a silent server (L1-1).** The keep-alive Read is
now bounded by its `TimeoutHint` (2 × `KeepAliveInterval`), which is
what Part 4 §7.32 says the client should do. It then fails with
`BadNoCommunication`, so reconnect starts after about 2 ×
`KeepAliveInterval` instead of `OperationTimeout` (120 s by default).
- **Operations fail fast during a channel-manager recovery (L1-2, G9).**
`UpdateIdentityAsync`, `TransferSubscriptionsAsync` and
`ReactivateSubscriptionsAsync` fail with `BadSecureChannelClosed`
(retryable) instead of deadlocking. This covers the operation holding
the reconnect lock and any queued behind it.
- **Closing or disposing from a handler no longer waits on itself (L1-3,
G5, G6).**
- `Session` and `ManagedSession` Close/Dispose from a Notification
handler no longer wait 30 s for their own background drain.
- Disposing a `ManagedSession` from a keep-alive `PublishStatusChanged`
handler no longer deadlocks.
- Tasks spawned by a handler no longer count as "the handler" (the owner
marker is scoped to the work item).
- **Switching to an ECC user-token policy takes two ActivateSession
calls (L1-4, G4).** This follows Part 6 §6.8.2 (fetch the ephemeral key,
then activate).
- If the server returns no key, the switch fails with
`BadSecurityPolicyRejected`.
- If the second activation is rejected, the old identity stays on the
override policy with the key just fetched. Restoring the old key was
tried: against the reference server the next reconnect then failed to
decrypt the identity token.
- **`ManagedSession.ConfiguredEndpoint` follows failover (L2-6).** It
now reports the inner session's endpoint, so it changes after a failover
or network-path rotation.
- **An explicit identity update replaces the identity provider (L2-7).**
`ManagedSession.UpdateSessionAsync(identity)` stops the provider's
refresh loop and drops the provider for good. If the update fails, the
loop is restarted.
- **Reconnect waits for in-flight calls on a healthy channel (L2-3,
G8a).** Reconnect/failover on a healthy channel (certificate or
trust-list change, manual reconnect) waits for in-flight service calls.
The wait is bounded (max(`KeepAliveInterval`, 1 s)) only once the
channel is unhealthy (keep-alive stopped, channel not Ready), so a dead
channel no longer holds recovery for `OperationTimeout`.
- **Smaller fixes:**
- Restore reads the server certificate as a chain; this failed on macOS
(L1-5).
- Session recovery can no longer run twice at once (L1-6).
- Identity expiries more than 49.7 days out no longer throw (L2-2).
- Keep-alive reconnect suppression is recalculated on a lease swap
(L2-4).
### Reconnect state machine and reverse connect
- **⚠ `MaxTotalReconnectTime = 0` now means "no time limit" (L3-7).
Reviewer decision needed.** Before, 0 disabled reconnect entirely.
Negative values other than `Timeout.InfiniteTimeSpan` now throw
`ArgumentOutOfRangeException` when the state machine is constructed;
before, they crashed the worker. The XML docs of
`ReconnectPolicy`/`ReconnectPolicyOptions` are updated. If 0 should keep
meaning "no reconnect", this is a one-line revert.
- **A close racing the end of the reconnect budget is no longer lost
(L3-4).** It goes through the normal close instead of switching to
failover.
- **The "reconnect worker" marker covers one step only (L2-1).** It
lasts for a single connect/reconnect/failover/close step. Keep-alive,
publish and identity loops started in a step no longer count as the
worker afterwards.
- **Reverse-connect strategies `Undefined` and `Any` alone are removed
after the first callback (L8-1).** This matches the documentation;
before, they behaved like `Always`.
- **`AddEndpoint` for a URL that is already added disposes the replaced
listener (L8-2).**
### Service calls
- **Batched calls clean up after a failed later batch (L3-2).** When a
later batch of
`Browse`/`BrowseNext`/`HistoryRead`/`CreateMonitoredItems` fails, the
continuation points and monitored items created by earlier batches are
released or deleted before the exception is rethrown. This adds one
request. `AddNodes`/`RegisterNodes` are not undone.
- **Batched diagnostic infos point at the right strings (L3-3).** The
string-table indexes are rebased when diagnostics are padded.
- **`ReadBytesAsync` rejects oversized responses (L3-5).** It throws
`BadUnknownResponse` when the server returns more bytes than requested,
and `BadEncodingLimitsExceeded` beyond `int` range.
- **`ReadByteStringInChunksAsync` handles an unknown server limit
(L3-6).** When the server's `MaxByteStringLength` is 0 (absent) or
larger than `int`, it falls back to the client's
`MessageContext.MaxByteStringLength`. It throws only when both are
unknown.
### Browse and NodeCache
- **Empty browse pages with a continuation point are followed (L9-1,
L9-4, L9-5, G16).** Part 4 §5.9.2.1 allows a server to return an empty
page with a continuation point, so browsing keeps following it. After 10
consecutive empty rounds it gives up with `BadNoData` and releases the
continuation point.
- `BrowseAsync(NodeId)` now throws instead of silently returning a
truncated list.
- `BrowseStreamAsync` yields a `BadNoData` result for that node.
- The same cap, through one shared helper, now also protects
`RoleManagementClient.ListRolesAsync` and the FileSystem
enumerate/empty-check loops.
- **Continuation points are released on failure (L9-3).**
`BrowseAsync(NodeId)` releases them on any exception, and
`BrowseStreamAsync` releases all points of the current page when the
consumer stops early.
- **NodeCache lookups propagate cancellation (L9-6, L9-8).**
`FindAsync`, `IsKnownAsync`, `FetchNodeAsync`,
`FindReferenceTypeNameAsync`, the reference-table fill and the
type-hierarchy search now let `OperationCanceledException` through.
Before, a cancelled lookup looked like "node does not exist".
- **A NodeClass mismatch fails only that node (L9-7).** It returns
`BadNodeClassInvalid` for that node instead of throwing
`KeyNotFoundException` for the whole batch.
- **Smaller fixes:**
- `MaxNodesPerBrowse` ≥ 2^31 no longer breaks the managed browse (L9-2).
- Null discovery URLs are skipped (L9-10).
- `ModelChangeTracker` no longer cancels a joined start (L9-11).
- **Log changes:** `BrowserContinuationPointExistsButBrowse` (Browser +
0) was removed, and `BrowserServerReturnedEmptyReferencesBut` now
includes the round count.
### Subscriptions (default engine)
- **Recovery after a transfer delivers each message exactly once (L5-1,
G22).** Messages already dispatched are acknowledged instead of
republished and delivered again. Messages whose earlier republish failed
are now republished and delivered, with the Republish flag and after
newer messages, instead of being acknowledged and lost.
- **A keep-alive that proves a gap triggers republish at once (L5-5,
G23).** The dedup mark moves only past messages that were actually
recovered, so a failed republish is retried by the next data message.
- **Item changes that fail are retried with backoff (L5-3).** They are
retried on the next apply pass after the state manager's 250 ms–5 s
backoff, instead of six times back to back. The six attempts now span
about 13 s.
- **Extreme revised intervals no longer throw (L5-4, L6-1).** Very large
or NaN intervals and counts revised by the server are capped (NaN falls
back to the requested value). Before, the overflow could stop every
publish worker.
- **`SetTriggeringAsync` from a callback no longer hangs (L5-7, G21).**
While the subscription is deleted, recreated or disposed, it returns
`BadOperationAbandoned`. A cancelled reset no longer makes later calls
fail.
- **Publish responses for unknown subscription ids (L6-3, L6-4, G20).**
The client no longer re-sends acknowledgements the server already
processed. An unknown id seen while a creation is pending is left alone
instead of being deleted.
- **Smaller fixes:**
- A null and an empty `IndexRange` are treated as equal, and restored
snapshots use `null` (L5-6).
- A failed partition attach during restore is rolled back (L6-2).
- `StreamingSubscription` throws `ObjectDisposedException` after dispose
(L6-6).
### Subscriptions (classic `Subscription`)
- **Republish no longer abandons other missing messages (L7-1).** A
Republish response no longer overwrites `AvailableSequenceNumbers`, so
other missing messages are not given up while the server still holds
them.
- **Late copies are no longer delivered twice (L7-2).** Copies of
already-processed sequence numbers are ignored, and creating a new
server subscription resets the sequence state.
- **Disposing from a keep-alive handler no longer deadlocks (L7-3).**
`Dispose()` from a keep-alive `PublishStatusChanged` handler can return
while that callback is still finishing.
- **Transfer of a restored subscription (L7-4, G27).**
- It succeeds even when some saved items were never created.
- The server subscription is deleted only when this session's own
TransferSubscriptions just moved it and adoption failed definitively
(item count mismatch or a non-transient status). Transient errors and
`ReactivateSubscriptionsAsync` never delete it.
- The public `Subscription.TransferAsync` never deletes.
- **Smaller fixes:**
- Cloned items keep their triggering links (L7-5).
- `CreateAsync` clears stale server ids (L7-6).
- A `Good_SubscriptionTransferred` for the client's own transfer is
ignored (L7-7).
- An item removed while its create is in flight is deleted afterwards
(L7-8).
- The publish timeout for very long keep-alives no longer overflows
(L7-9).
- A negative `MaxMessageCount` is handled (L7-10).
- `LastMessage` tolerates a wrong notification type (L7-11).
- Pending acks exactly 2^31 away no longer throw (L4-1).
- **Log changes:** new log events `Subscription + 68..70`.
### Redundancy
- **Warm/Hot demotion (L4-2, G1, G3).**
- Without token reuse, the demoted leader closes its session and
reconnects as a fresh standby.
- With token reuse, it abandons the shared server session locally,
without CloseSession, so the new leader can reactivate it and keep its
subscriptions. This uses the new internal methods
`ManagedSession.AbandonServerSessionAsync` and
`Session.AbandonServerSession`.
- `RoleChanged(false)` is now raised before the standby connect and no
longer depends on it, so `CurrentSession` can briefly be `null`.
- Exceptions from `RoleChanged` handlers are logged and do not stop the
teardown.
- **Promotion (L4-3, G2).** A promoted standby that adopts the leader's
session first closes its own server session. A retried promotion keeps
the session it already adopted.
- **Client failover takeover (L4-4, partial).** Subscriptions the backup
prepared in advance (not yet created, `TransferId` set to the active
client's id) are moved through the session's own transfer and keep
publishing. `docs/HighAvailability.md` is updated.
- **`ShouldFailover` through the interface (L4-6).** Calling it through
`IServerRedundancyHandler` no longer fails over away from a server that
is still Healthy.
### Fluent / dependency injection
- **Shared DI session (L10-5).** The shared session from `AddClient` is
reconnected after a caller disposes it, and is disposed with the
container. After that the connect delegate throws
`ObjectDisposedException`.
- **New public API:
`ManagedSessionBuilder.WithServerRedundancyOptions(ServerRedundancyOptions)`
(L10-6).** This is additive. The `Session.ServerRedundancy` options are
now actually applied.
- **`AddDiscoveryAndConnect` rewrites the advertised address (L10-7).**
It replaces the host and port the server advertises with those of the
discovery URL (Part 4 §5.6.2.1, NAT/containers), and prefers endpoints
with the same scheme.
### State machines and role management
- **`GetSubStateMachineAsync` returns the instance's own sub-state
machine (L10-1).** Before, it returned the type's placeholder.
- **`WaitForStateAsync` reports failures with OPC UA status codes
(L10-2).** It throws `BadNotFound` when `CurrentState/Id` cannot be
resolved, and `BadInvalidState` when observation ends without reaching
the state. Before, both surfaced as `OperationCanceledException`.
- **`RoleManagementClient.ListRolesAsync` pages correctly (L10-3).** It
follows continuation points, releases them on any failure, and throws on
a Bad browse status instead of returning an empty list.
- **Role properties are read into the right fields (L10-4).** Properties
whose namespace cannot be resolved are skipped instead of shifting the
other values.
### File system, temporary files and alias names
- **Path escaping (L11-8, G12).**
- Path segments escape `:` and `/` with `&`, and `&` itself as `&&`
before those characters or at the end of a name.
- `Format` and `Parse` round-trip all names. Typed input ending in `&/`
now reads the slash as part of the name.
- A segment without a namespace prefix also matches a namespace-0 child
when nothing matches in the parent's namespace.
- **Stale path cache (L11-1).** When a cached lookup fails,
`GetInfoAsync` retries once from the server. `ExistsAsync` and
`GetFileAsync` report "not found" instead of throwing.
- **`GenerateFileForReadAsync` waits for the file (L11-2).** It waits
until the file-generation state machine reaches ReadTransfer (`Idle`
counts as not started yet), and throws `IOException` if it reports
Error. The wait is bounded only by the caller's token.
- **Non-recursive delete refuses hidden children (L11-3).** It refuses
when the directory holds children that the subtype options hide from
enumeration. Before, the server deleted them all.
- **Alias names (L11-5, L11-6, L11-7).**
- `BadMethodInvalid` maps to `NotSupportedException` for all alias
calls, so `UseVerbose` falls back to `FindAlias`.
- A duplicate alias name resolves to all its targets.
- If the refresh strategy fails to start, the resolver logs a warning
and keeps working without automatic invalidation. New log event block
`AliasNameResolver = 560`.
- **Null profile entries (L11-4).** Null `ServerProfileArray` entries no
longer crash `GetConformanceInfoAsync`.
### WebApi transport
- **Failures come back as `ServiceResultException` (L12-1).**
- A timeout becomes `BadRequestTimeout`.
- HTTP 429/503 becomes `BadServerTooBusy` with the Retry-After hint, on
every target framework. `WebApiClient` checks the status itself (reusing
`HttpsTransportChannel.CreateServerTooBusyException`), so callers of
`WebApiClient` also get a `ServiceResultException` instead of an
`HttpRequestException` for these two statuses (G15).
- An IO error becomes `BadNotConnected`.
- **Timeouts (L12-1, G14).**
- `OperationTimeout` applies to each request. When `RequestTimeout` is
unset, HttpClient's 100 s default no longer cuts off long Publish
requests.
- The GetEndpoints call at open is bounded by `OperationTimeout`, and
cancelling the open now throws.
- **Core change:** `HttpsTransportChannel.MapRequestFailure` went from
private to internal so the WebApi channel can reuse it.
### ComplexTypes
- **Out-of-range union selector (L12-3).** A union whose SwitchField is
larger than its field count now fails with `BadDecodingError` when
decoded and `BadEncodingError` when encoded (Part 6 §5.2.8).
- **Verbose JSON unions (L12-4).** They now decode their selected field
instead of `null`.
## Not fixed / partial
- **L2-5 (skipped):** an identity provider combined with a
caller-supplied reverse connection still can't connect to an
undiscovered endpoint. Fixing it means choosing the identity after
CreateSession, a design change.
- **L4-5 (skipped):** the one-time KeyCredential token needs a fresh
token on each reactivation. Today reactivation fails and the client
falls back to a new session.
- **L4-4 (partial):** only subscriptions the backup prepared in advance
survive a takeover.
- **L3-2 (partial):** `AddNodes`/`RegisterNodes` batches are not rolled
back.
- **L2-3 (partial):** service calls still hold the session lock for
their whole round trip.
- **Known from the review, not fixed here:**
- G7: the batch cleanup request can't be cancelled.
- G11: the DI session's close at host shutdown never reaches the server,
and a closed session keeps being handed out.
- G13: a file renamed by another client is still found under its old
path.
- G25: a clone-based transfer doesn't mark the original subscriptions.
- G27b: the transfer item check matches by count, not by ID.
- **Outside this PR:** the reference server ignores `ECDHPolicyUri` on
ActivateSession (`SessionSecurityPolicyHelper`), which breaks Part 6
§6.8.2. As a result, the L1-4 two-step ECC switch ends in
`BadSecurityPolicyRejected` against our own server.
## Testing
All suites on net10.0, on the branch before the master merge. Every
suite had 0 failures:
| Suite | Passed | Skipped |
|---|---|---|
| Opc.Ua.Client.Tests | 2698 | 0 |
| Opc.Ua.Redundancy.Client.Tests | 134 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 |
| Opc.Ua.Bindings.Https.WebApi.Tests | 385 | 0 |
| Opc.Ua.Sessions.Tests | 782 | 379 |
| Opc.Ua.Subscriptions.Tests | 640 | 6 |
| Opc.Ua.Client.ComplexTypes.Tests | 2858 | 0 |
After merging current master (net10.0), every suite had 0 failures
except one, a flaky new test fixed in 9f52952b2:
| Suite | Passed | Skipped | Failed |
|---|---|---|---|
| Opc.Ua.Client.Tests | 2719 | 0 | 1 (the flaky test) |
| Opc.Ua.Redundancy.Client.Tests | 134 | 0 | 0 |
| Opc.Ua.Client.ComplexTypes.Tests | 2858 | 0 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 | 0 |
| Opc.Ua.Bindings.Https.WebApi.Tests | 397 | 0 | 0 |
| Opc.Ua.Sessions.Tests | 790 | 379 | 0 |
| Opc.Ua.Subscriptions.Tests | 640 | 6 | 0 |
The touched test projects also build on net48 with 0 warnings.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
951 lines
39 KiB
C#
951 lines
39 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.RateLimiting;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Http.Resilience;
|
|
using Opc.Ua.Bindings;
|
|
using Opc.Ua.Client.WebApi;
|
|
using Opc.Ua.Identity;
|
|
|
|
namespace Opc.Ua.Client
|
|
{
|
|
/// <summary>
|
|
/// Fluent builder for constructing a <see cref="ManagedSession"/>. The
|
|
/// builder accumulates an immutable <see cref="ManagedSessionOptions"/>
|
|
/// snapshot via <c>With*</c>/<c>Use*</c>/<c>Configure</c> methods. Call
|
|
/// <see cref="Build"/> to obtain the snapshot, or
|
|
/// <see cref="ConnectAsync"/> to construct and open the session in one
|
|
/// step.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <code>
|
|
/// var session = await new ManagedSessionBuilder(configuration, telemetry)
|
|
/// .UseEndpoint(endpoint)
|
|
/// .WithSessionName("My App")
|
|
/// .WithReconnectPolicy(p => p with { Strategy = BackoffStrategy.Linear })
|
|
/// .ConnectAsync(ct);
|
|
/// </code>
|
|
/// </example>
|
|
public sealed class ManagedSessionBuilder
|
|
{
|
|
private readonly ApplicationConfiguration m_configuration;
|
|
private readonly ITelemetryContext m_telemetry;
|
|
private ManagedSessionOptions m_options = new();
|
|
private IReconnectPolicy? m_reconnectPolicy;
|
|
private IServerRedundancyHandler? m_redundancyHandler;
|
|
private ISessionFactory? m_sessionFactory;
|
|
private ISecurityPolicyRegistry? m_securityPolicies;
|
|
private IClientChannelManager? m_channelManager;
|
|
private Action<HttpStandardResilienceOptions>? m_httpsResilience;
|
|
private WebApiClientOptions? m_webApiOptions;
|
|
private ReverseConnectManager? m_reverseConnectManager;
|
|
private Uri? m_reverseConnectServerUri;
|
|
|
|
/// <summary>
|
|
/// Initializes a new builder.
|
|
/// </summary>
|
|
public ManagedSessionBuilder(
|
|
ApplicationConfiguration configuration,
|
|
ITelemetryContext telemetry)
|
|
{
|
|
m_configuration = configuration
|
|
?? throw new ArgumentNullException(nameof(configuration));
|
|
m_telemetry = telemetry
|
|
?? throw new ArgumentNullException(nameof(telemetry));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use the supplied configured endpoint.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="endpoint"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder UseEndpoint(ConfiguredEndpoint endpoint)
|
|
{
|
|
if (endpoint == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(endpoint));
|
|
}
|
|
m_options = m_options with { Endpoint = endpoint };
|
|
ApplyReverseConnectEndpoint();
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a discovery endpoint URL with optional security mode/policy.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="url"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder UseEndpoint(
|
|
string url,
|
|
MessageSecurityMode securityMode = MessageSecurityMode.SignAndEncrypt,
|
|
string securityPolicyUri = SecurityPolicies.Basic256Sha256)
|
|
{
|
|
if (url == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(url));
|
|
}
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = url,
|
|
SecurityMode = securityMode,
|
|
SecurityPolicyUri = securityPolicyUri
|
|
};
|
|
return UseEndpoint(new ConfiguredEndpoint(null, endpointDescription, null));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shortcut that targets an OPC UA HTTPS Web API endpoint
|
|
/// (OPC UA Part 6 §G.3 "OpenAPI Mapping"). The endpoint
|
|
/// description is constructed with
|
|
/// <see cref="Profiles.HttpsOpenApiTransport"/>,
|
|
/// <see cref="MessageSecurityMode.None"/>, and
|
|
/// <see cref="SecurityPolicies.None"/> — Web API relies on
|
|
/// TLS / transport-layer authentication
|
|
/// (configured via <see cref="WithWebApiAuthentication"/>).
|
|
/// The endpoint advertises a permissive set of
|
|
/// <see cref="UserTokenPolicy"/> entries (Anonymous + UserName +
|
|
/// Certificate + IssuedToken / JWT) so the client can present
|
|
/// any identity supported by the server; the server-side
|
|
/// <c>DefaultSessionlessIdentityProvider</c> still enforces what
|
|
/// it accepts.
|
|
/// </summary>
|
|
/// <param name="url">
|
|
/// The server's base URL, e.g. <c>https://server:4843/</c>.
|
|
/// </param>
|
|
/// <param name="encoding">
|
|
/// The OPC UA JSON encoding flavour to advertise. Defaults to
|
|
/// <see cref="WebApiEncoding.Compact"/> per Part 6 §5.4.9.
|
|
/// </param>
|
|
/// <returns>The builder for chaining.</returns>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="url"/> is <c>null</c>.
|
|
/// </exception>
|
|
public ManagedSessionBuilder UseWebApiEndpoint(
|
|
string url,
|
|
WebApiEncoding encoding = WebApiEncoding.Compact)
|
|
{
|
|
if (url == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(url));
|
|
}
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = url,
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
TransportProfileUri = Profiles.HttpsOpenApiTransport,
|
|
UserIdentityTokens = new ArrayOf<UserTokenPolicy>(
|
|
new[]
|
|
{
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Anonymous",
|
|
TokenType = UserTokenType.Anonymous,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
},
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Username",
|
|
TokenType = UserTokenType.UserName,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
},
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Certificate",
|
|
TokenType = UserTokenType.Certificate,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
}
|
|
})
|
|
};
|
|
m_webApiOptions ??= new WebApiClientOptions();
|
|
m_webApiOptions.Encoding = encoding;
|
|
return UseEndpoint(new ConfiguredEndpoint(null, endpointDescription, null));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shortcut that targets the WSS variant of the OPC UA OpenAPI
|
|
/// mapping (Part 6 §7.5.2 sub-protocol <c>opcua+openapi</c>;
|
|
/// surfaced via <see cref="Profiles.WssOpenApiTransport"/>,
|
|
/// OPC Foundation profile/2339). Same envelope as
|
|
/// <see cref="UseWebApiEndpoint(string, WebApiEncoding)"/> but
|
|
/// multiplexed over a WebSocket text-frame transport.
|
|
/// </summary>
|
|
/// <param name="url">
|
|
/// The server's WSS URL, e.g. <c>wss://server:4843/</c>.
|
|
/// </param>
|
|
/// <param name="encoding">
|
|
/// The OPC UA JSON encoding flavour. Defaults to
|
|
/// <see cref="WebApiEncoding.Compact"/>.
|
|
/// </param>
|
|
/// <returns>The builder for chaining.</returns>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="url"/> is <c>null</c>.
|
|
/// </exception>
|
|
public ManagedSessionBuilder UseWssOpenApiEndpoint(
|
|
string url,
|
|
WebApiEncoding encoding = WebApiEncoding.Compact)
|
|
{
|
|
if (url == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(url));
|
|
}
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = url,
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
TransportProfileUri = Profiles.WssOpenApiTransport,
|
|
UserIdentityTokens = new ArrayOf<UserTokenPolicy>(
|
|
new[]
|
|
{
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Anonymous",
|
|
TokenType = UserTokenType.Anonymous,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
},
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Username",
|
|
TokenType = UserTokenType.UserName,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
},
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "Certificate",
|
|
TokenType = UserTokenType.Certificate,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
}
|
|
})
|
|
};
|
|
m_webApiOptions ??= new WebApiClientOptions();
|
|
m_webApiOptions.Encoding = encoding;
|
|
return UseEndpoint(new ConfiguredEndpoint(null, endpointDescription, null));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures Web API transport authentication
|
|
/// (Bearer / Basic / mTLS via <c>HttpMessageHandler</c>).
|
|
/// Applies to the per-session
|
|
/// <see cref="WebApiTransportChannel"/> constructed when
|
|
/// <see cref="ConnectAsync"/> resolves a
|
|
/// <see cref="Profiles.HttpsOpenApiTransport"/> endpoint.
|
|
/// </summary>
|
|
/// <param name="configure">
|
|
/// Callback that mutates a fresh
|
|
/// <see cref="WebApiClientOptions"/> instance.
|
|
/// </param>
|
|
/// <returns>The builder for chaining.</returns>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="configure"/> is <c>null</c>.
|
|
/// </exception>
|
|
public ManagedSessionBuilder WithWebApiAuthentication(
|
|
Action<WebApiClientOptions> configure)
|
|
{
|
|
if (configure == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(configure));
|
|
}
|
|
m_webApiOptions ??= new WebApiClientOptions();
|
|
configure(m_webApiOptions);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use reverse connect for the configured endpoint.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="manager"/> or
|
|
/// <paramref name="serverUri"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder UseReverseConnect(
|
|
ReverseConnectManager manager,
|
|
Uri serverUri)
|
|
{
|
|
if (manager is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(manager));
|
|
}
|
|
if (serverUri is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(serverUri));
|
|
}
|
|
|
|
m_reverseConnectManager = manager;
|
|
m_reverseConnectServerUri = serverUri;
|
|
ApplyReverseConnectEndpoint();
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the user identity used when activating the session.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="identity"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithUserIdentity(IUserIdentity identity)
|
|
{
|
|
if (identity == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(identity));
|
|
}
|
|
#pragma warning disable CS0618 // Legacy eager identity remains supported for compatibility.
|
|
m_options = m_options with { Identity = identity };
|
|
#pragma warning restore CS0618
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the lazy identity provider used to refresh identities after connect.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="provider"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithIdentityProvider(IClientIdentityProvider provider)
|
|
{
|
|
if (provider == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(provider));
|
|
}
|
|
m_options = m_options with { IdentityProvider = provider };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the time provider used by proactive identity refresh.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="timeProvider"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithTimeProvider(TimeProvider timeProvider)
|
|
{
|
|
if (timeProvider == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(timeProvider));
|
|
}
|
|
m_options = m_options with { TimeProvider = timeProvider };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the session display name.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentException"></exception>
|
|
public ManagedSessionBuilder WithSessionName(string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
throw new ArgumentException("Value required", nameof(name));
|
|
}
|
|
m_options = m_options with { SessionName = name };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the requested session timeout.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
public ManagedSessionBuilder WithSessionTimeout(TimeSpan timeout)
|
|
{
|
|
if (timeout <= TimeSpan.Zero)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(timeout));
|
|
}
|
|
m_options = m_options with { SessionTimeout = timeout };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the preferred locales for the session.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="locales"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithPreferredLocales(params string[] locales)
|
|
{
|
|
if (locales == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(locales));
|
|
}
|
|
m_options = m_options with { PreferredLocales = locales };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enable or disable server-certificate domain validation.
|
|
/// </summary>
|
|
public ManagedSessionBuilder WithCheckDomain(bool checkDomain = true)
|
|
{
|
|
m_options = m_options with { CheckDomain = checkDomain };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configure the reconnect policy via a transformation of the
|
|
/// underlying <see cref="ReconnectPolicyOptions"/> record.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="configure"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithReconnectPolicy(
|
|
Func<ReconnectPolicyOptions, ReconnectPolicyOptions> configure)
|
|
{
|
|
if (configure == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(configure));
|
|
}
|
|
m_options = m_options with
|
|
{
|
|
ReconnectPolicy = configure(m_options.ReconnectPolicy)
|
|
};
|
|
m_reconnectPolicy = null;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the channel recovery deadline. Null selects the automatic session-derived bound;
|
|
/// <see cref="Timeout.InfiniteTimeSpan"/> explicitly disables it.
|
|
/// </summary>
|
|
/// <param name="timeout">The maximum duration of a channel recovery cycle.</param>
|
|
/// <returns>This builder.</returns>
|
|
/// <exception cref="ArgumentOutOfRangeException">
|
|
/// The finite duration is not a supported positive timeout.
|
|
/// </exception>
|
|
public ManagedSessionBuilder WithChannelReconnectTimeout(TimeSpan? timeout)
|
|
{
|
|
if (!ManagedSessionOptions.IsValidChannelReconnectTimeout(timeout))
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(timeout));
|
|
}
|
|
m_options = m_options with { ChannelReconnectTimeout = timeout };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use the supplied <see cref="IReconnectPolicy"/> directly. Overrides
|
|
/// any options-based reconnect configuration.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"></exception>
|
|
public ManagedSessionBuilder WithReconnectPolicy(IReconnectPolicy policy)
|
|
{
|
|
m_reconnectPolicy = policy
|
|
?? throw new ArgumentNullException(nameof(policy));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a shared gate to asynchronously limit concurrent initial
|
|
/// connect attempts.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="connectGate"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithConnectRateLimiter(IClientConnectGate connectGate)
|
|
{
|
|
if (connectGate == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(connectGate));
|
|
}
|
|
m_options = m_options with { ConnectGate = connectGate };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a shared <see cref="RateLimiter"/> to asynchronously limit
|
|
/// concurrent initial connect attempts.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="rateLimiter"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithConnectRateLimiter(RateLimiter rateLimiter)
|
|
{
|
|
if (rateLimiter == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(rateLimiter));
|
|
}
|
|
#pragma warning disable CA2000 // Lifetime is owned by the builder/options; TODO: model owned gate disposal explicitly.
|
|
return WithConnectRateLimiter(new RateLimiterClientConnectGate(rateLimiter));
|
|
#pragma warning restore CA2000
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a shared concurrency limiter to asynchronously limit
|
|
/// concurrent initial connect attempts.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
public ManagedSessionBuilder WithConnectRateLimiter(int maxConcurrency)
|
|
{
|
|
#pragma warning disable CA2000 // Lifetime is owned by the builder/options; TODO: model owned gate disposal explicitly.
|
|
return WithConnectRateLimiter(new RateLimiterClientConnectGate(maxConcurrency));
|
|
#pragma warning restore CA2000
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a central <see cref="IClientChannelManager"/> so that
|
|
/// multiple sessions to the same endpoint share one underlying
|
|
/// transport channel and reconnect is coordinated centrally.
|
|
/// </summary>
|
|
/// <param name="channelManager">The channel manager.</param>
|
|
/// <exception cref="ArgumentNullException"></exception>
|
|
public ManagedSessionBuilder WithChannelManager(IClientChannelManager channelManager)
|
|
{
|
|
m_channelManager = channelManager
|
|
?? throw new ArgumentNullException(nameof(channelManager));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configure the standard HTTP resilience handler used by HTTPS transport channels.
|
|
/// </summary>
|
|
/// <param name="configure">The HTTP resilience configuration delegate.</param>
|
|
/// <exception cref="ArgumentNullException"><paramref name="configure"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithHttpsResilience(Action<HttpStandardResilienceOptions> configure)
|
|
{
|
|
m_httpsResilience = configure
|
|
?? throw new ArgumentNullException(nameof(configure));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enable server redundancy with a default handler.
|
|
/// </summary>
|
|
public ManagedSessionBuilder WithServerRedundancy()
|
|
{
|
|
m_options = m_options with { EnableServerRedundancy = true };
|
|
m_redundancyHandler = null;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enable server redundancy with the supplied handler.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"></exception>
|
|
public ManagedSessionBuilder WithServerRedundancy(IServerRedundancyHandler handler)
|
|
{
|
|
m_redundancyHandler = handler
|
|
?? throw new ArgumentNullException(nameof(handler));
|
|
m_options = m_options with { EnableServerRedundancy = true };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the timeouts the default server redundancy handler uses for the
|
|
/// redundancy metadata read and peer endpoint lookup. Raise them on a
|
|
/// high-latency link. Takes effect when server redundancy is enabled and
|
|
/// no custom handler is supplied.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder WithServerRedundancyOptions(ServerRedundancyOptions options)
|
|
{
|
|
if (options == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(options));
|
|
}
|
|
m_options = m_options with { ServerRedundancy = options };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a specific subscription engine factory. Defaults to the V2
|
|
/// engine when not specified.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="factory"/> is <c>null</c>.</exception>
|
|
public ManagedSessionBuilder UseSubscriptionEngine(ISubscriptionEngineFactory factory)
|
|
{
|
|
if (factory == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(factory));
|
|
}
|
|
m_options = m_options with { SubscriptionEngineFactory = factory };
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opt the V2 subscription engine into transfer-on-recreate.
|
|
/// When enabled, the V2 <c>SubscriptionManager</c> attempts to
|
|
/// transfer existing server-side subscriptions from the
|
|
/// previous session to the new one on each session re-create
|
|
/// (e.g. failover via
|
|
/// <c>Session.RecreateInPlaceAsync</c>) and falls back
|
|
/// to per-subscription recreate when transfer is not
|
|
/// available. Disabled by default — recreate is the
|
|
/// universal, server-agnostic fallback; transfer requires
|
|
/// server support.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Has no effect when the classic subscription engine is in
|
|
/// use; the classic engine drives recreate through the
|
|
/// <see cref="Session"/>'s template-based path.
|
|
/// </remarks>
|
|
public ManagedSessionBuilder WithTransferSubscriptionsOnRecreate(
|
|
bool transferOnRecreate = true)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
TransferSubscriptionsOnRecreate = transferOnRecreate
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opt into token-reuse fast reconnect on failover (OPC UA Part 4 §6.6).
|
|
/// When enabled, a failover to a redundant server
|
|
/// re-activates the existing session by reusing the current
|
|
/// <c>AuthenticationToken</c> instead of creating a new session, and
|
|
/// falls back to re-authentication if the standby rejects the token.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Disabled by default (re-authentication on failover). Requires the
|
|
/// server side to mirror session state; the standby still performs the
|
|
/// full <c>ActivateSession</c> signature validation, so the token alone
|
|
/// never admits a session.
|
|
/// </remarks>
|
|
public ManagedSessionBuilder WithTokenReuseFailover(bool enable = true)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
EnableTokenReuseFailover = enable
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures non-transparent network redundancy endpoints for one logical server.
|
|
/// </summary>
|
|
public ManagedSessionBuilder WithNetworkRedundancy(
|
|
ArrayOf<ConfiguredEndpoint> alternateEndpoints)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
NetworkRedundancy = new NetworkRedundancyOptions
|
|
{
|
|
AlternateEndpoints = alternateEndpoints
|
|
}
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enable activator-level pooling of V2 subscription
|
|
/// notification payload instances. When enabled, the V2
|
|
/// subscription dispatcher calls
|
|
/// <see cref="IPooledEncodeable.Reuse"/> on notification
|
|
/// payload objects (such as <c>MonitoredItemNotification</c>)
|
|
/// after each handler dispatch, releasing them back to their
|
|
/// activator's pool for reuse on the next publish. Handlers
|
|
/// that retain values past the dispatch call must copy them.
|
|
/// Disabled by default.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Has no effect when the classic subscription engine is in
|
|
/// use; this option only applies to the V2
|
|
/// <see cref="Subscriptions.ISubscriptionManager"/>.
|
|
/// </remarks>
|
|
public ManagedSessionBuilder WithPoolNotifications(
|
|
bool poolNotifications = true)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
PoolNotifications = poolNotifications
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables address-space model change tracking. The session
|
|
/// auto-starts a <see cref="ModelChange.IModelChangeTracker"/>
|
|
/// after connect that invalidates the node cache and exposes
|
|
/// changes via <see cref="ManagedSession.ModelChange"/>.
|
|
/// Disabled by default.
|
|
/// </summary>
|
|
public ManagedSessionBuilder WithModelChangeTracking(
|
|
bool enabled = true)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
ModelChangeTracking = enabled
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables or disables automatic complex-type loading after connect.
|
|
/// Disabled by default.
|
|
/// </summary>
|
|
public ManagedSessionBuilder WithLoadComplexTypes(
|
|
bool enabled = true)
|
|
{
|
|
m_options = m_options with
|
|
{
|
|
LoadComplexTypes = enabled
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a specific session factory. By default, the builder
|
|
/// creates a new <see cref="DefaultSessionFactory"/> configured with
|
|
/// the V2 subscription engine.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"></exception>
|
|
public ManagedSessionBuilder UseSessionFactory(ISessionFactory factory)
|
|
{
|
|
m_sessionFactory = factory
|
|
?? throw new ArgumentNullException(nameof(factory));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use a specific security policy registry. The session and the
|
|
/// channels it opens resolve policy URIs against this set, so a policy
|
|
/// the application contributed through <c>AddSecurityPolicy</c> is
|
|
/// usable end to end. By default,
|
|
/// <see cref="SecurityPolicies.Default"/> is used.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"></exception>
|
|
public ManagedSessionBuilder UseSecurityPolicies(
|
|
ISecurityPolicyRegistry securityPolicies)
|
|
{
|
|
m_securityPolicies = securityPolicies
|
|
?? throw new ArgumentNullException(nameof(securityPolicies));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the immutable options snapshot accumulated so far.
|
|
/// </summary>
|
|
public ManagedSessionOptions Build()
|
|
{
|
|
return m_options;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Construct and connect a <see cref="ManagedSession"/> using the
|
|
/// accumulated options.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException"></exception>
|
|
public async Task<ManagedSession> ConnectAsync(CancellationToken ct = default)
|
|
{
|
|
ManagedSessionOptions opts = m_options;
|
|
if (opts.Endpoint == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"ManagedSessionBuilder.UseEndpoint must be called before ConnectAsync.");
|
|
}
|
|
|
|
ISubscriptionEngineFactory engineFactory =
|
|
opts.SubscriptionEngineFactory ??
|
|
(opts.TimeProvider == null
|
|
? DefaultSubscriptionEngineFactory.Instance
|
|
: new DefaultSubscriptionEngineFactory(opts.TimeProvider));
|
|
|
|
ISessionFactory sessionFactory = m_sessionFactory ??
|
|
new DefaultSessionFactory(m_telemetry)
|
|
{
|
|
SubscriptionEngineFactory = engineFactory,
|
|
TimeProvider = opts.TimeProvider,
|
|
SecurityPolicyRegistry = m_securityPolicies
|
|
};
|
|
|
|
IReconnectPolicy reconnect =
|
|
m_reconnectPolicy ?? new ReconnectPolicy(opts.ReconnectPolicy);
|
|
|
|
// Redundancy is transparent and on by default: a non-redundant server simply
|
|
// reports RedundancySupport.None and no failover occurs, while transparent and
|
|
// non-transparent redundant servers are handled without any special connect API.
|
|
IServerRedundancyHandler redundancy = m_redundancyHandler ??
|
|
new DefaultServerRedundancyHandler(
|
|
new DefaultRedundantServerEndpointResolver(m_telemetry),
|
|
opts.TimeProvider,
|
|
opts.ServerRedundancy);
|
|
|
|
IClientChannelManager? channelManager = m_channelManager;
|
|
ClientChannelManager? ownedChannelManager = null;
|
|
ServiceProviderHttpClientFactory? ownedHttpClientFactory = null;
|
|
try
|
|
{
|
|
if (channelManager == null && m_httpsResilience != null)
|
|
{
|
|
ownedHttpClientFactory = CreateHttpsHttpClientFactory(m_httpsResilience);
|
|
ownedChannelManager = new ClientChannelManager(
|
|
m_configuration,
|
|
m_telemetry,
|
|
BuildChannelBindings(
|
|
new HttpsTransportChannelBindings(
|
|
DefaultTransportBindingRegistry.WithDefaultTcp(),
|
|
ownedHttpClientFactory)),
|
|
reconnectPolicy: null,
|
|
timeProvider: opts.TimeProvider,
|
|
options: null,
|
|
securityPolicies: m_securityPolicies);
|
|
channelManager = ownedChannelManager;
|
|
}
|
|
else if (channelManager == null && IsWebApiEndpoint(opts.Endpoint))
|
|
{
|
|
ownedChannelManager = new ClientChannelManager(
|
|
m_configuration,
|
|
m_telemetry,
|
|
BuildChannelBindings(DefaultTransportBindingRegistry.WithDefaultTcp()),
|
|
reconnectPolicy: null,
|
|
timeProvider: opts.TimeProvider,
|
|
options: null,
|
|
securityPolicies: m_securityPolicies);
|
|
channelManager = ownedChannelManager;
|
|
}
|
|
|
|
ManagedSession session = await ManagedSession.CreateAsync(
|
|
opts with { SubscriptionEngineFactory = engineFactory },
|
|
m_configuration,
|
|
sessionFactory,
|
|
reconnect,
|
|
redundancy,
|
|
m_telemetry,
|
|
channelManager,
|
|
m_reverseConnectManager,
|
|
ct: ct).ConfigureAwait(false);
|
|
session.OwnTransportResources(ownedChannelManager, ownedHttpClientFactory);
|
|
return session;
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
if (ownedChannelManager != null)
|
|
{
|
|
await ownedChannelManager.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ownedHttpClientFactory?.Dispose();
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void ApplyReverseConnectEndpoint()
|
|
{
|
|
if (m_options.Endpoint != null && m_reverseConnectServerUri != null)
|
|
{
|
|
m_options.Endpoint.ReverseConnect = new ReverseConnectEndpoint
|
|
{
|
|
Enabled = true,
|
|
ServerUri = m_reverseConnectServerUri.ToString()
|
|
};
|
|
}
|
|
}
|
|
|
|
private static ServiceProviderHttpClientFactory CreateHttpsHttpClientFactory(
|
|
Action<HttpStandardResilienceOptions> configure)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddHttpClient(OpcUaHttpClientDefaults.ClientName)
|
|
.AddStandardResilienceHandler(configure);
|
|
services.TryAddSingleton<IOpcUaHttpClientFactory, DefaultOpcUaHttpClientFactory>();
|
|
|
|
ServiceProvider serviceProvider = services.BuildServiceProvider();
|
|
try
|
|
{
|
|
return new ServiceProviderHttpClientFactory(serviceProvider);
|
|
}
|
|
catch
|
|
{
|
|
serviceProvider.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static bool IsWebApiEndpoint(ConfiguredEndpoint? endpoint)
|
|
{
|
|
string? profile = endpoint?.Description?.TransportProfileUri;
|
|
return Profiles.IsHttpsOpenApi(profile) || Profiles.IsWssOpenApi(profile);
|
|
}
|
|
|
|
private ITransportChannelBindings BuildChannelBindings(ITransportChannelBindings inner)
|
|
{
|
|
// Wraps the inner registry so the synthetic
|
|
// Utils.UriSchemeOpcHttpsWebApi / opc.wss+openapi keys resolve
|
|
// to WebApi[Wss]TransportChannelFactory configured with the
|
|
// auth options accumulated via WithWebApiAuthentication /
|
|
// UseWebApiEndpoint / UseWssOpenApiEndpoint. Inner bindings
|
|
// continue to handle every other URI scheme (opc.tcp,
|
|
// opc.https, opc.wss, ...).
|
|
if (m_webApiOptions == null && !IsWebApiEndpoint(m_options.Endpoint))
|
|
{
|
|
return inner;
|
|
}
|
|
return new WebApiAwareChannelBindings(
|
|
inner,
|
|
new WebApiTransportChannelFactory(m_webApiOptions),
|
|
new WebApiWssTransportChannelFactory(m_webApiOptions));
|
|
}
|
|
|
|
private sealed class WebApiAwareChannelBindings : ITransportChannelBindings
|
|
{
|
|
public WebApiAwareChannelBindings(
|
|
ITransportChannelBindings inner,
|
|
WebApiTransportChannelFactory webApiFactory,
|
|
WebApiWssTransportChannelFactory wssOpenApiFactory)
|
|
{
|
|
m_inner = inner;
|
|
m_webApiFactory = webApiFactory;
|
|
m_wssOpenApiFactory = wssOpenApiFactory;
|
|
}
|
|
|
|
public ITransportChannel? Create(string uriScheme, ITelemetryContext telemetry)
|
|
{
|
|
if (string.Equals(uriScheme, Utils.UriSchemeOpcHttpsWebApi, StringComparison.Ordinal))
|
|
{
|
|
return m_webApiFactory.Create(telemetry);
|
|
}
|
|
if (string.Equals(uriScheme, Utils.UriSchemeOpcWssOpenApi, StringComparison.Ordinal))
|
|
{
|
|
return m_wssOpenApiFactory.Create(telemetry);
|
|
}
|
|
return m_inner.Create(uriScheme, telemetry);
|
|
}
|
|
|
|
private readonly ITransportChannelBindings m_inner;
|
|
private readonly WebApiTransportChannelFactory m_webApiFactory;
|
|
private readonly WebApiWssTransportChannelFactory m_wssOpenApiFactory;
|
|
}
|
|
|
|
private sealed class ServiceProviderHttpClientFactory : IOpcUaHttpClientFactory, IDisposable
|
|
{
|
|
public ServiceProviderHttpClientFactory(ServiceProvider serviceProvider)
|
|
{
|
|
m_serviceProvider = serviceProvider
|
|
?? throw new ArgumentNullException(nameof(serviceProvider));
|
|
m_httpClientFactory = serviceProvider.GetRequiredService<IOpcUaHttpClientFactory>();
|
|
}
|
|
|
|
public HttpClient CreateClient(string name)
|
|
{
|
|
return m_httpClientFactory.CreateClient(name);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
m_serviceProvider.Dispose();
|
|
}
|
|
|
|
private readonly ServiceProvider m_serviceProvider;
|
|
private readonly IOpcUaHttpClientFactory m_httpClientFactory;
|
|
}
|
|
}
|
|
}
|