Files
UA-.NETStandard/docs/Transports.md
romanett 61edaa1ed0 Remove netstandard2.1 and net472 target frameworks (#4624)
# Description

Drops support for **netstandard2.1** and **.NET Framework 4.7.2**.
Libraries now target `net48`, `net8.0`, `net9.0` and `net10.0`.
`Opc.Ua.Types` and `Opc.Ua.SourceGeneration.Core` still also target
`netstandard2.0`, but only because the Roslyn source generators load
them. There is no general netstandard2.0 support.

**Preprocessor directives** (112 files)
- Every `#if`/`#elif` mentioning `NETSTANDARD2_1*`, `NET472*`,
`NET_STANDARD_TESTS`, `SKIP_ECC_CERTIFICATE_REQUEST_SIGNING` or
`NETSTANDARD*` was evaluated against the remaining TFM set (net48,
net8/9/10, plus netstandard2.0 inside `Opc.Ua.Types`/`tools`) and
simplified. Dead branches were removed.
- Example: `NETSTANDARD2_1 || NET8_0_OR_GREATER` → `NET8_0_OR_GREATER`.
- Example: `NET8_0_OR_GREATER || NET472_OR_GREATER` → removed (always
true).
- Netstandard-only code was removed: `UnixFileModeShim`, the CRDT
reflection path in `ReplicatedGossipOptions.CreateReaderOptions`, and
the netstandard2.1-only test variants.

**Custom test logic**
- `targets.props` drops the `net472`, `netstandard2.0` and
`netstandard2.1` `CustomTestTarget` profiles, `NetStandardTests` and
`SKIP_ECC_CERTIFICATE_REQUEST_SIGNING`. Supported values are now
`net48`, `net8.0`, `net9.0`, `net10.0`.
- The `NET_STANDARD_TESTS` define is removed from 29 test projects.
- The "build netstandard shells as net10.0" overrides are removed from
18 projects.
- `RestrictForLegacyTfm` now only applies to `net48`.
- `tests/customtest.bat` is updated to the new values.

**Build / packaging / CI**
- MSBuild TFM conditions are simplified: Kestrel package choice, MQTTnet
v4 (net48 only), BouncyCastle, Di/OpenUsd/Vision/Stress test excludes,
and `System.IO.FileSystem.AccessControl` (dropped from Core).
- The MigrationAnalyzer nuspec drops its net472/netstandard2.1 lib
groups. The Azure signing lists are updated.
- `get-ci-matrix.ps1` removes the net472/netstandard test profiles
(including the PR-gating `linux-netstandard2.1`) and the matching build
legs.

**Breaking API change (2.0 preview):** removed
`HttpsTransportListener.IsWssTransportSupported` and
`ReplicatedGossipOptions.IsGossipStateDecodingSupported`. They only
returned `false` on netstandard2.1 and are now always `true`. The test
guards that used them were removed as well.

**Docs**
- NuGet READMEs, `README.md`, `DeveloperGuide.md`, `PubSub.md`, the
certificate/ECC/identity docs and the 2.0 migration guide (plus its
`.agents` skill mirror) are updated.
- The migration guide notes that 4.7.2 consumers must move to 4.8 and
netstandard2.1 consumers to net8.0+.

### Reviewer notes
- The `System.IO.FileSystem.AccessControl` `PackageVersion` pin is left
in `Directory.Packages.props`. It has no direct reference any more, but
transitive pinning is on, so it was kept to avoid shifting resolved
versions.
- The `#if` rewrite was done mechanically by a truth-table evaluator.
The diff in the `.cs` files is limited to directive lines, removed dead
branches, and stale-comment updates.

### Verified locally
- `dotnet build UA.slnx -p:CustomTestTarget=net48`: 0 errors.
- `dotnet build UA.slnx -p:CustomTestTarget=net10.0`: 0 errors.
- Default multi-target builds (no `CustomTestTarget`) of Opc.Ua.Types,
Bindings.Https, PubSub.Mqtt and Redundancy.Server succeeded.
- net10.0 tests:
  - Opc.Ua.Redundancy.Server.Tests: 840/840 passed.
  - Opc.Ua.Types.Tests: 11363 passed, 1 skipped.
  - ModelDependencyScanner tests: 20/20 passed.
- The full test suite was **not** run locally.

## Related Issues

- None

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:09:49 +02:00

631 lines
31 KiB
Markdown

# Transport Profiles
This document is the practical companion to [`Profiles.md`](Profiles.md) and
describes how the OPC UA .NET Standard stack ships each of the transport
profiles defined in OPC UA Part 6 §7. It is aimed at developers who need
to host or connect to OPC UA servers over something other than the
default `opc.tcp://` transport — for example to traverse firewalls or
to integrate with web-based tooling.
## Contents
- [Transport profile matrix](#transport-profile-matrix)
- [Assembly layout](#assembly-layout)
- [Server-side configuration](#server-side-configuration)
- [Client-side usage](#client-side-usage)
- [Discovery](#discovery)
- [Opt-in: Kestrel-hosted `opc.tcp`](#opt-in-kestrel-hosted-opctcp)
- [Implementing a custom byte transport](#implementing-a-custom-byte-transport)
- [See also](#see-also)
## Transport profile matrix
| Profile | URL scheme | Wire format | UA Secure Conversation | Security modes |
|---|---|---|---|---|
| `uatcp-uasc-uabinary` | `opc.tcp://` | UA Binary | yes | `None`, `Sign`, `SignAndEncrypt` |
| `https-uabinary` | `opc.https://`, `https://` | UA Binary in HTTP body (`application/octet-stream`) | yes (one chunk per POST) | `None`, `Sign`, `SignAndEncrypt` |
| `https-uajson` | `opc.https://`, `https://` | UA JSON in HTTP body (`application/opcua+uajson`) | **no** — TLS only | `None` only |
| [`https-uajson-openapi`](https://profiles.opcfoundation.org/profile/2338) | `opc.https://`, `https://` | OpenAPI Mapping (Part 6 §G.3): per-service `POST /<service>` with body = `<Service>Request` JSON (`application/json; encoding=compact\|verbose`) | **no** — TLS only | `None` only |
| `wss-uasc-uabinary` | `opc.wss://`, `wss://` | UA Binary in WebSocket binary frame (sub-protocol `opcua+uacp`) | yes | `None`, `Sign`, `SignAndEncrypt` |
| `wss-uajson` | `opc.wss://`, `wss://` | UA JSON in WebSocket text frame (sub-protocol `opcua+uajson`) | **no** — TLS only | `None` only |
| [`wss-uajson-openapi`](https://profiles.opcfoundation.org/profile/2339) | `opc.wss://`, `wss://` | OpenAPI Mapping over WebSocket text frame (sub-protocol `opcua+openapi` / `opcua+openapi+<accesstoken>`) | **no** — TLS only | `None` only |
The JSON / OpenAPI profiles do not negotiate a UA SecureChannel; transport
security is provided exclusively by the surrounding TLS connection.
Servers MUST advertise these endpoints with `MessageSecurityMode.None`
and `SecurityPolicyUri = None`. The `https-uajson` and WSS JSON
profiles use the Compact (reversible) flavour mandated by
Part 6 §5.4.9 (`JsonEncoderOptions.Compact`). The OpenAPI profiles
select between Compact (default, mandatory) and Verbose via
the `application/json; encoding=compact|verbose` media-type parameter
on `Content-Type` / `Accept` — see [`WebApi.md`](WebApi.md) for the
full mapping table.
The OPC UA Profiles surface exposes the two OpenAPI profile URIs as
`Profiles.HttpsOpenApiTransport` (profile/2338) and
`Profiles.WssOpenApiTransport` (profile/2339); the `HttpsServiceHost`
emits the HTTPS twin as a discovery-only `EndpointDescription`
alongside each `SecurityMode.None` HTTPS-binary endpoint so discovery
clients see the OpenAPI route without hard-coding the URL.
Discovery retains binary, JSON and OpenAPI endpoints even when their URL and
security settings are identical. `GetEndpoints` profile filters select only
the requested profiles, including when translating an alternate hostname.
Managed client channels likewise include the effective transport profile in
their sharing key: equal URLs do not allow different wire encodings to share
an underlying channel.
The managed channel manager retains its own client certificate and chain.
Open attempts borrow independent snapshots, and each installed transport has
separate references until it is closed. Closing or rotating one channel does
not retire keys still used by another channel or an in-flight open.
Managed sessions prefer the active certificate registry over an older configured
store entry; unmanaged sessions retain explicit configured-store selection.
A session opened on a managed transport uses that transport's
owned certificate snapshot, including during recreation after rotation; a
later registry update cannot substitute a different key into that handshake.
Reconnects recheck certificate generations before reactivating sessions and
reporting readiness. Publishing identical certificate/issuer material does not
force another transport replacement; a changed issuer chain still does.
Rotation that supersedes successful work does not consume the failed-attempt
retry allowance. Reconnect time budgets and shutdown cancellation still apply.
UA-TCP reconnect hands the new connection to the retained channel without
closing it when the temporary handshake channel is retired. ECC and RSA-DH
handoffs move the owned ephemeral nonce objects, including the private key;
public nonce bytes cannot reconstruct them. The retained token's secret is
used when deriving the replacement keys. Continued sequence numbers are
checked against the retained channel, not treated as a new secure channel.
A rejected or failed handoff closes the new connection and releases its
unadopted token rather than restarting an orphaned receive loop. The peer
certificate remains available through failure auditing. Receive loops
have separate cancellation lifetimes; a retiring connection cannot stop its
replacement. Connection admission reserves capacity before invoking channel
callbacks, retires idle channels outside the listener lock, and closes rejected
sockets. Invalid response sequences fail pending requests promptly with
`BadSecurityChecksFailed`; diagnostics retain `BadSequenceNumberInvalid`.
Raw TCP, Kestrel TCP, and UACP WebSocket listeners honor the configured
`ConnectionRateLimiter` before allocating a channel or accepting a WebSocket
upgrade. Kestrel and UACP WebSocket listeners reserve their configured
`MaxChannelCount` capacity atomically, including pending admissions. The
reservation follows the physical transport through a reverse-connect handoff
and is released on closure, cancellation, failed attachment, or listener stop.
The host retains ownership of a supplied limiter. These limits are not
per-tenant fairness policies, and HTTP/JSON requests are not counted as UASC
channels.
### Committed session bindings
Managed servers expose `ISessionBindingProvider` to their listeners.
`SessionManager` maintains distinct committed session/channel membership:
reactivation does not add another session, transfer moves only that session's
binding, and closure, timeout cleanup, or shutdown removes it. Resource
eligibility no longer depends on counting activation and close responses.
`TryGetSessionContext` provides an immutable `SessionBindingContext` after a
read-only lookup of a live, activated, unexpired session and its transport
binding. It does not refresh session activity or perform service authorization.
Full service validation is still required; re-query after queueing and compare
snapshot identity and activation sequence before reusing classification.
Sequences belong to a live session instance, so a restored session's snapshot
must not be confused with the earlier instance. Anonymous sessions do not
establish trusted tenants, and identity keys must not become unbounded metric
labels.
`StandardServer` uses its current session manager's optional capability by
default. A custom manager without that capability grants no managed session
classification. Core-only standalone channels without a provider retain
legacy response-count hints for compatibility; those hints are not authoritative
membership and must not be used to grant trusted reservations.
### Incomplete-message resource limits
UA Secure Conversation channels enforce message-size and chunk-count limits
including the incoming chunk, before retaining it. Exceeding a limit releases
the partial message and closes the channel; no final chunk is required to
trigger cleanup. A valid Abort chunk is not additional message payload: it
discards the partial message without consuming its size, chunk-count, or
reassembly-budget allowance. The channel stays open; a client reports the
peer's abort status for that request.
On server channels, `ChannelLifetime` also bounds assembly of an incomplete
message from its first retained chunk. Further chunks or other activity do not
restart that deadline. Channels sharing listener quotas and a clock share one
assembly timer, checked at half-lifetime intervals. Idle channels do not take
the partial-message lock during that check. The timer is released when the last
channel leaves the scope. This also works in bindings without an inactivity
sweep. For assembly deadlines, a zero or negative `ChannelLifetime` uses the
30-second transport default rather than disabling cleanup; other quota values
are not changed. Expiry attempts an ERR carrying `BadTimeout` before closing,
and cleanup still completes if that error cannot be sent.
Completed or discarded messages clear their assembly state,
and subsequent messages receive a fresh deadline. This applies before an OPC UA
session is created and to the UA-TCP, Kestrel TCP, and WebSocket bindings using
the server-channel pipeline.
Per-channel limits alone do not bound memory across many connections.
`ChunkReassemblyBudget` counts the actual arrays retained for intermediate
chunks across all listeners of a server. Its default is **64 MiB** for the
reference server, with half reserved as headroom for channels carrying activated
sessions. Reservations are released on completion, replacement, abort, fault, or
closure. Final chunks and normal response allocations are not charged.
A rejected chunk releases the partial message, reports
`BadTcpNotEnoughResources` when an error response can be sent, and closes the
channel permanently. General buffer allocation remains unrestricted by default.
See [reassembly-budget configuration](RateLimiting.md#incomplete-messages) for
sizing and scope. `MaxSessionCount` does not limit connections that have not
created a session.
Hello negotiation also updates TCP and WebSocket receive-buffer sizes. The
Kestrel pipe transport rents for the actual chunk size instead of the listener
maximum, avoiding unnecessary retained capacity for small chunks.
## Assembly layout
* **`Opc.Ua.Core`** (this is what every Server / Client application
references):
- `IUaSCByteTransport` — the runtime transport boundary used by the
UA Secure Conversation channel pipeline.
- `TcpByteTransport`, `TcpByteTransportFactory` — TCP implementation.
- `TcpTransportChannel` + `TcpTransportChannelFactory` — client-side
channel for `opc.tcp://`.
- `HttpsTransportChannel` + `HttpsTransportChannelFactory`,
`OpcHttpsTransportChannelFactory` — polymorphic client-side channel
that handles both `https-uabinary` and `https-uajson` based on the
endpoint's `TransportProfileUri`.
* **`Opc.Ua.Bindings.Https`** (Kestrel-dependent; pulled in
automatically when an HTTPS or WSS endpoint is configured):
- `HttpsTransportListener` — Kestrel-hosted listener that dispatches
requests on `(Upgrade, ContentType)` to the binary / JSON / WSS
handlers.
- `HttpsTransportListenerFactory`, `OpcHttpsTransportListenerFactory`
— listener factories for `https://` and `opc.https://`.
- `WssTransportListenerFactory`, `OpcWssTransportListenerFactory` —
listener factories for `wss://` and `opc.wss://`.
- `WebSocketClientByteTransport` + `WebSocketClientByteTransportFactory`,
`WssTransportChannel`, `WssTransportChannelFactory`,
`OpcWssTransportChannelFactory` — client-side WSS+uacp.
- `WssJsonTransportChannel`, `WssJsonTransportChannelFactory` —
client-side WSS+uajson (per-request `ClientWebSocket`).
- `JsonRequestMapper` — shared JSON encode / decode helper used by
both server handlers.
For both WSS variants the HTTPS / WSS factories live in
`Opc.Ua.Bindings.Https`; consumers register them via the
`AddHttpsTransport()` / `AddWssTransport()` DI extensions on
`IOpcUaBuilder` or a specialized server builder, or by constructing a
`DefaultTransportBindingRegistry` and calling `RegisterListenerFactory` /
`RegisterChannelFactory` directly for non-DI hosts. Applications must
reference `OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` directly; Server
and GDS packages do not pull the optional binding transitively.
`AddHttpsTransport(options => ...)` is available as a one-shot registration
for the Kestrel HTTPS stack. It registers HTTPS and WSS by default and can
attach the WebApi binding and authentication callbacks in the same fluent
chain. `AddWebApiTransport()` and `AddKestrelOpcTcpTransport()` are
order-independent with the underlying HTTPS/TCP registrations.
## Server-side configuration
Servers declare endpoints via `ApplicationConfiguration` →
`ServerConfiguration` → `BaseAddresses`. Each base address yields a
single listener for its scheme.
If a discovery or hosted server has a non-TCP base address but the matching
transport binding has not been registered, startup throws a clear error that
names the missing scheme and suggested `Add*Transport()` method.
```xml
<BaseAddresses>
<ua:String>opc.tcp://localhost:62541/MyServer</ua:String>
<ua:String>opc.https://localhost:62540/MyServer</ua:String>
<ua:String>opc.wss://localhost:62543/MyServer</ua:String>
</BaseAddresses>
```
* The `opc.tcp` listener uses the lightweight raw-socket
`TcpTransportListener` by default (no ASP.NET Core dependency). An
opt-in alternative shipped in `Opc.Ua.Bindings.Https` (`net8.0`+) is
described below.
* The `opc.https` and `opc.wss` listeners both share a single
`HttpsTransportListener` per `(host, port)` — Kestrel routes the
inbound HTTP request to the right handler based on the
`Upgrade` / `Content-Type` headers. Concretely:
- `POST` with `Content-Type: application/octet-stream` →
`https-uabinary` handler.
- `POST` with `Content-Type: application/opcua+uajson` →
`https-uajson` handler.
- HTTP Upgrade with `Sec-WebSocket-Protocol: opcua+uacp` →
full UASC WebSocket session.
- HTTP Upgrade with `Sec-WebSocket-Protocol: opcua+uajson` →
request/response JSON WebSocket session.
### Security configuration
The HTTPS and WSS listeners share the same TLS configuration via
`HttpsConnectionAdapterOptions`. By default the listener serves
`SecurityMode.None`; setting
`ServerConfiguration.HttpsMutualTls = true` requires the client to
present a TLS certificate which is matched against the
`ClientCertificate` field of the UASC `OpenSecureChannelRequest`.
The JSON sub-profiles (`https-uajson` and the WSS `opcua+uajson`
sub-protocol) only accept `SecurityMode.None` regardless of the
configured security policies — see Part 6 §7.4.5 / §7.5.2 for the
spec rationale.
All HTTPS and WSS client bindings, including WebApi, require the TLS certificate
to match the endpoint hostname before invoking a configured OPC UA certificate
validator. Trusting a certificate or its issuer does not bypass hostname
verification. This also applies to TLS-only JSON bindings.
All HTTP, WebSocket and WebApi dispatch paths forward the observed remote IP
address for authentication lockout accounting. Unsecured clients cannot reset
that bucket by opening a new connection or changing their ApplicationUri.
When a transport cannot supply a peer address, the fallback bucket is
session-local, never shared by every client of a listener.
## Client-side usage
The client API is the standard `Session` + `EndpointDescription` flow;
the transport is selected automatically from
`EndpointDescription.TransportProfileUri`:
```csharp
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var application = new ApplicationInstance(telemetry)
{
ApplicationName = "MyClient",
ApplicationType = ApplicationType.Client
};
ApplicationConfiguration configuration = await application
.Build("urn:localhost:MyClient", "urn:localhost:product")
.AsClient()
.AddSecurityConfiguration("CN=MyClient")
.Create()
.ConfigureAwait(false);
// 1) Direct connect to a known opc.wss endpoint (binary UASC):
EndpointDescription wssEndpoint = CoreClientUtils.SelectEndpoint(
application,
"opc.wss://server.example.com:62543/MyServer",
useSecurity: true);
ISession session = await Session.CreateAsync(
configuration, new ConfiguredEndpoint(null, wssEndpoint), false,
"WssSession", 60_000, null, null).ConfigureAwait(false);
// 2) HTTPS-JSON endpoint (Security Mode None):
var jsonEndpoint = new EndpointDescription
{
EndpointUrl = "opc.https://server.example.com:62540/MyServer",
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
TransportProfileUri = Profiles.HttpsJsonTransport
};
ISession jsonSession = await Session.CreateAsync(
configuration, new ConfiguredEndpoint(null, jsonEndpoint), false,
"JsonSession", 60_000, null, null).ConfigureAwait(false);
```
A few notes:
* The `opc.wss://` scheme is the OPC UA alias; the stack normalises it
to `wss://` (the IETF scheme) and falls back to
`Utils.UaWebSocketsDefaultPort` (4843) when no explicit port is
supplied.
* The WSS client requires the server to select the requested WebSocket
sub-protocol. If the server returns anything else the connection
fails with `BadNotConnected`.
* The WSS-JSON client (`opcua+uajson`) opens a fresh `ClientWebSocket` per request
(simplest correct behaviour). A pooled / persistent-WebSocket
variant can be added without changing the public shape.
* The HTTPS client (binary or JSON) reuses a single `HttpClient` per
channel; the encoding is selected from
`EndpointDescription.TransportProfileUri` at request time.
The WSS OpenAPI client (`opcua+openapi`) keeps a persistent connection and
multiplexes requests, so an outstanding Publish does not block other services.
It assigns unique, nonzero wire request handles for that connection while
preserving caller-owned request objects and caller-visible response handles.
Concurrent requests must use distinct caller handles; a handle can be reused
after completion, cancellation or timeout. The shared `ClientBase` caller-handle
sequence can wrap; it is separate from the connection-local wire allocator.
Terminal requests release their pending state without waiting for a server reply, and late replies cannot
complete a newer request.
An attributable response-decoding failure, such as exceeding `MaxArrayLength`,
fails only that request. Other pending requests and subsequent requests keep
using the connection. Transport/framing failures and responses that cannot be
attributed to an issued wire handle still close it.
Caller cancellation and request timeout cancel send-lock admission and the
pending response, not an already-started WebSocket send. An admitted send uses
the connection's shutdown token and finishes independently of the caller;
shutdown drains it before disposing the socket.
`CancelRequest.RequestHandle` is translated to the wire handle of the currently
outstanding request with that caller handle. A retired or unknown target maps
to a reserved, never-issued wire handle, so it cannot cancel an unrelated request.
Wire handles never wrap: exhaustion closes the connection with
`BadConnectionClosed` and requires reopening it. Shutdown closes operation
admission, cancels queued sends and close operations, and drains admitted
socket users before disposing connection resources.
HTTPS binary, JSON, and OpenAPI clients obtain response headers before
buffering the body. A positive `MaxMessageSize` limits the actual body bytes, including
chunked responses or misleading `Content-Length` values. An oversized
response fails with `BadResponseTooLarge`; the reader consumes at most one
byte beyond the limit to detect the overflow. HTTP error responses are
disposed without buffering their bodies.
The request deadline and caller cancellation remain active while the
response body is being read. The binary/JSON transport reports socket
connection failures as `BadNotConnected`, socket or request timeouts as
`BadRequestTimeout`, and other HTTP failures as `BadUnknownResponse`, while
preserving the original exception for diagnostics. HTTP 429/503 retain
their `BadServerTooBusy` and `Retry-After` handling.
## Discovery
`GetEndpoints` and `FindServers` return the `EndpointDescription` list
declared by the listener for each base address. For TCP, HTTPS-binary,
and WSS+uacp the description includes the correct `TransportProfileUri`
(`uatcp-uasc-uabinary`, `https-uabinary`, `wss-uasc-uabinary`
respectively). The JSON sub-protocols are reachable on the same URL as
their binary counterparts and clients select them explicitly via the
`Content-Type` header (HTTPS) or the `Sec-WebSocket-Protocol` header
(WSS). The HTTPS / WSS service hosts also emit a discovery-only
**JSON-twin** `EndpointDescription` (`TransportProfileUri =
Profiles.HttpsJsonTransport` / `Profiles.UaWssJsonTransport`,
`SecurityMode = None`) for every `SecurityMode = None` binary endpoint,
so JSON-aware clients see the profile in `GetEndpoints` without needing
to construct the description themselves. The twin is discovery-only
(no separate listener) — the existing binary listener handles content
negotiation.
## Opt-in: Kestrel-hosted `opc.tcp`
By default the stack ships **two** listener implementations for
`opc.tcp://`:
| Package | Listener | When to use |
| --- | --- | --- |
| `Opc.Ua.Core` (default) | `TcpTransportListener` | Raw `Socket` + `SocketAsyncEventArgs`. No ASP.NET Core dependency. The right choice for trimmed / AOT deployments and for environments that already avoid pulling in `Microsoft.AspNetCore.App`. |
| `Opc.Ua.Bindings.Https` (opt-in, `net8.0`+) | `KestrelTcpTransportListener` | Hosts `opc.tcp://` on Kestrel via `Microsoft.AspNetCore.Connections.ConnectionHandler`. Lets a single `IHost` serve `opc.tcp`, `opc.https`, and `opc.wss` so consumers manage one HTTP-style runtime, share TLS plumbing, and share observability middleware. |
The Kestrel-TCP listener uses the same `IUaSCByteTransport` runtime
boundary as the raw-socket listener, so the UASC channel pipeline is
unchanged. It supports the full feature set the raw-socket listener
does, including:
* forward (server) and **reverse-connect** listener modes,
* per-listener TLS certificate hot-update via
`ITransportListenerCertificateRotation`,
* discovery (`EndpointDescription` emission via the shared
`TcpServiceHost` base).
Swap it in by registering the factory through the DI container
**before** opening any `opc.tcp` listener (typically at application
startup):
```csharp
// Standalone non-DI consumers:
DefaultTransportBindingRegistry registry =
DefaultTransportBindingRegistry.WithDefaultTcp();
registry.RegisterListenerFactory(new KestrelTcpTransportListenerFactory());
// Pass to ServerBase (via ctor / TransportBindings setter) before StartAsync.
// Microsoft.Extensions.DependencyInjection consumers:
services
.AddOpcUa()
.AddOpcTcpTransport() // raw-socket opc.tcp default
.AddKestrelOpcTcpTransport(); // last-writer-wins: overrides with Kestrel
```
`AddKestrelOpcTcpTransport()` installs an `ITransportBindingConfigurator`
that runs after `AddOpcTcpTransport()`, so the registry resolves the
Kestrel listener factory for `opc.tcp://`. The same pattern applies to
`AddHttpsTransport()` / `AddWssTransport()` (HTTPS / WSS) and any custom
binding registered via `AddCustomTransport<TListener, TChannel>()`.
> **`AddOpcTcpTransport()` is optional.** `AddOpcUa()` already seeds the mandatory raw-socket `opc.tcp` listener and channel factories into the transport binding registry, so clients and servers reach `opc.tcp://` without it. Call it only to be explicit; the `Add*Transport()` extensions above still override the seeded defaults (last-writer-wins per URI scheme) whether or not it was called.
The package targets net8.0+ only (the ASP.NET Core `ConnectionContext`
API surface used to bridge `Socket`-like semantics — `LocalEndPoint`,
`RemoteEndPoint`, `ConnectionClosed` — is not consistently available
on older TFMs). Consumers on net48 continue to use
the raw-socket default.
## Implementing a custom byte transport
The OPC UA Secure Conversation (UASC) binary channel pipeline talks to
the wire through a narrow byte-level abstraction —
[`IUaSCByteTransport`](../src/Opc.Ua.Core/Stack/Tcp/IUaSCByteTransport.cs).
It is the public extension point for plugging in custom transports
beyond the built-in TCP and WebSocket implementations.
### When you need this
Use cases that are good fits for a custom byte transport:
- **Named pipes / Unix domain sockets** for fast loopback IPC.
- **QUIC / HTTP/3** to replace the TCP transport's congestion behaviour.
- **In-process bridges** for unit tests or co-located server/client pairs
that want to skip the network stack entirely.
- **Tunnels** (SSH, custom L4) that wrap the UASC chunks in another
framing.
Use cases that should NOT use this:
- A new sub-protocol that changes UASC framing — that lives inside the
channel implementation, not the transport.
- HTTPS-binary or HTTPS-JSON variants — those are handled in
`Opc.Ua.Bindings.Https` outside the UASC pipeline.
### Contract summary
```csharp
public interface IUaSCByteTransport
{
string Implementation { get; } // diagnostic id
TransportChannelFeatures Features { get; } // optional capabilities
EndPoint? LocalEndpoint { get; } // may be null
EndPoint? RemoteEndpoint { get; } // may be null
ValueTask ConnectAsync(Uri url, CancellationToken ct);
ValueTask SendChunkAsync(ReadOnlyMemory<byte> chunk, CancellationToken ct);
ValueTask SendChunkAsync(BufferCollection buffers, CancellationToken ct);
ValueTask<ArraySegment<byte>> ReceiveChunkAsync(CancellationToken ct);
void Close();
}
public interface IUaSCByteTransportFactory
{
string Implementation { get; }
IUaSCByteTransport Create(
BufferManager bufferManager,
int receiveBufferSize,
ITelemetryContext telemetry);
}
```
Each Send / Receive operates on **exactly one** complete UASC
`MessageChunk` (Part 6 §6.7.2). The channel pipeline above the transport
owns chunk-level framing — your job is to move chunk bytes across the
wire.
### Implementation checklist
- [ ] Buffer ownership. Buffers returned by `ReceiveChunkAsync` are
rented from the supplied `BufferManager`; the caller (the UASC
channel) returns them via `BufferManager.ReturnBuffer` once the
chunk has been processed. Do **not** pool or reuse the same array
across receives.
- [ ] Idempotent `Close`. The channel may dispose a transport from
multiple paths (normal shutdown, fatal error, channel-state race);
every implementation MUST treat `Close()` as a no-op when already
closed.
- [ ] Cancellation. `ReceiveChunkAsync` should observe the cancellation
token while awaiting peer data — long-lived sessions rely on this
to tear down cleanly.
- [ ] Error mapping. Map transport-layer errors to
[`ServiceResultException`](../src/Opc.Ua.Types/Utils/ServiceResultException.cs)
with the matching `StatusCodes.BadXxx` (`BadConnectionClosed`,
`BadTcpMessageTypeInvalid`, `BadTcpMessageTooLarge`, …) so the
channel can route them through normal UA fault paths.
- [ ] Vectored Send. The `BufferCollection` overload is called by the
channel for chunks that span multiple buffers (typical for the
asymmetric handshake). If your transport does not support
vectored writes, concatenate the segments into a single buffer
before sending (this is what `WebSocketByteTransportBase` does).
- [ ] Client vs server. Client transports implement `ConnectAsync` to
dial outbound; server transports built from an
already-accepted connection should throw `NotSupportedException`
from `ConnectAsync` (this is the contract enforced by
`UaSCUaBinaryClientChannel`).
### Wiring the transport into the channel pipeline
#### Client side
Implement [`IUaSCByteTransportFactory`](../src/Opc.Ua.Core/Stack/Tcp/IUaSCByteTransport.cs)
and hand the factory to a subclass of
`UaSCUaBinaryTransportChannel`:
```csharp
internal sealed class MyTransportFactory : IUaSCByteTransportFactory
{
public string Implementation => "UA-MY";
public IUaSCByteTransport Create(BufferManager bm, int rxSize, ITelemetryContext tel)
=> new MyByteTransport(bm, rxSize, tel);
}
public sealed class MyTransportChannel : UaSCUaBinaryTransportChannel
{
public MyTransportChannel(ITelemetryContext telemetry)
: base(new MyTransportFactory(), telemetry)
{
}
}
public sealed class MyTransportChannelFactory : ITransportChannelFactory
{
public string UriScheme => "opc.my";
public ITransportChannel Create(ITelemetryContext telemetry)
=> new MyTransportChannel(telemetry);
}
```
If your transport needs channel-level state (TLS validator, client cert,
custom credentials) plumbed in, override
`UaSCUaBinaryTransportChannel.OnSettingsSaved(TransportChannelSettings,
ChannelQuotas)` — that hook fires after the channel binds settings but
before it tries to connect. The WSS implementation uses this pattern;
see `WssTransportChannel.OnSettingsSaved` for a reference.
#### Server side
Implement [`ITransportListener`](../src/Opc.Ua.Core/Stack/Transport/ITransportListener.cs)
plus an `ITransportListenerFactory`. For each accepted connection,
construct your transport directly (not through the
`IUaSCByteTransportFactory`, which is client-side only), then hand it to
a `TcpServerChannel` via `Attach(channelId, transport)`. The WSS server
path in `HttpsTransportListener.AcceptWebSocketAsync` is the canonical
template.
#### Registering by URL scheme
Install your custom listener and channel factories into the host's
`ITransportBindingRegistry`. The simplest path uses the DI extension:
```csharp
services
.AddOpcUa()
.AddCustomTransport<MyCustomListenerFactory, MyCustomChannelFactory>();
```
Both factories are resolved from the `IServiceProvider` (so they may
have constructor-injected dependencies) and installed under the URI
scheme reported by `MyCustomListenerFactory.UriScheme`. Non-DI consumers
construct a `DefaultTransportBindingRegistry` and call
`RegisterListenerFactory` / `RegisterChannelFactory` directly, then hand
the registry to `ServerBase` (via `TransportBindings`) or to
`ReverseConnectManager.TransportBindings`.
### Worked example
[`InProcessTransport`](../src/Opc.Ua.Core/Stack/Tcp/InProcessTransport.cs)
is the public reference implementation that ships in `Opc.Ua.Core`. It
uses only public API: two paired transports communicate over a pair of
in-memory `System.Threading.Channels.Channel<byte[]>` (one per
direction). Use it directly for unit tests or co-located client/server
pairs that want to skip the network stack entirely, or as a
copy-paste starting point for a custom transport.
```csharp
using Opc.Ua.Bindings;
var buffers = new BufferManager("inproc", 8192, telemetry);
(InProcessTransport client, InProcessTransport server) =
InProcessTransport.CreatePair(buffers, receiveBufferSize: 8192, telemetry);
await client.SendChunkAsync(payload, ct).ConfigureAwait(false);
ArraySegment<byte> received = await server.ReceiveChunkAsync(ct).ConfigureAwait(false);
```
Note that `InProcessTransport.ConnectAsync` throws
`NotSupportedException` — the pair is created up front via
`CreatePair`. A real network transport would implement `ConnectAsync`
to dial the wire.
## See also
* [`Profiles.md`](Profiles.md) — supported profiles, security policies,
message encodings.
* [`MigrationGuide.md`](MigrationGuide.md) — note on the breaking
removal of `IMessageSocket` and the new `IUaSCByteTransport`
contract.
* OPC UA Part 6 §7.4 (HTTPS) / §7.5 (WebSockets) for the wire-format
specification.