# Description Correct ownership, endpoint validation, policy propagation, subscription delivery, file handling and generated-model behavior across the stack, tools and samples. Add focused regression tests and update the corresponding guides and sample documentation. ### Previously included corrections - Require an active, matching session to generate or access transient ResultTransfer file handles. Cover both sessionless generation and reads against a handle owned by an active session, using live session owners in the Machinery tests. - Keep physical filesystem operations within their configured mount and apply the same confinement checks throughout file operations. - Validate LDS semaphore paths consistently during server registration and registration-store maintenance. ### Individual issues addressed | # | Component | Issue and correction | |---|---|---| | 1 | AI jobs and transfers | Jobs and transfers did not consistently retain their creator's identity and session. Apply ownership checks to descendants, file handles, subscriptions and execution, and recheck deployment permissions before dispatch. | | 2 | Password-management client | Password-bearing calls could use signing-only channels or follow a replacement connection. Capture an encrypted transport generation and keep dispatch bound to it; preserve ordinary non-password edits. | | 3 | GDS and certificate-push clients | Connections omitted hostname verification. Validate the original configured DNS name or IP address before session activation. | | 4 | Certificate push | Private-key uploads could use signing-only calls. Require an encrypted, generation-bound dispatch while preserving certificate-only updates and restoring the caller's identity afterward. | | 5 | MQTT client | The .NET Framework custom-trust callback could discard TLS hostname, certificate-availability and usage errors. Retain those checks while supporting configured issuer trust. | | 6 | DTLS handshake | Certificate trust was not tied to the configured peer address. Match the certificate's DNS or IP identity to the intended endpoint. | | 7 | Replicated address spaces | Serialized nodes lost per-node and descendant permission metadata. Preserve role permissions, user-role permissions and access restrictions, including updates to existing replica nodes. | | 8 | MCP PubSub imports | Imports without groups skipped checks on the effective broker destination. Check the actual endpoint before resolving credentials or activating the connection. | | 9 | Reference-server accounts | A disabled username account could authenticate again with its previous roles. Reject disabled accounts during fresh authentication. | | 10 | Console reference client | The sample disabled hostname checks when connecting to an otherwise trusted peer. Retain the original requested host and validate it before activation. | | 11 | Capture replay and export | Replay and raw export could accept non-capture files or include key material. Require complete PCAP/PCAPNG containers and exclude key sidecars and decryption-secret blocks. | | 12 | PubSub adapter fallback | The fallback client configuration automatically accepted peer certificates. Apply normal trust and hostname validation before activation or reads. | | 13 | Kafka transport | Explicit TLS enablement was checked but did not reach both final Confluent configurations. Use the same effective protocol for validation, producers and consumers. | | 14 | PubSub actions | Configuration rebuilds could widen a connection-local action handler's scope. Preserve its original connection association and reject mismatched dispatch. | | 15 | PubSub readers | Reader-level message-protection settings were accepted but ignored. Apply the effective group and reader settings before accepting and publishing received values. | | 16 | PubSub key groups | Different `SecurityGroupId` values could share one connection-wide key context. Reject mixed groups on a connection and require separate connections for distinct groups. | | 17 | MCP session registrations | Session-name replacement or reuse could retain another registration's credential bindings. Bind access to the registration, caller and authenticated endpoint, and preserve that identity when borrowing sessions. | | 18 | Viewer HTTP host | Requests reached the viewer's OPC UA session without consistent Host, Origin and port checks. Validate the request destination and browser origin before using the session, while retaining supported local MCP clients. | | 19 | Vision file operations | File operations did not consistently apply protected-file checks, and remote handles lacked caller and connection context. Apply the shared checks and use host-side opaque handles bound to caller, target and generation. | | 20 | DTLS record integrity | The integrity-only MAC used a truncated sequence number. Authenticate the full reconstructed sequence number and epoch so records remain distinct across rollover. | | 21 | AMB client sample | Connection setup omitted hostname validation. Preserve the originally requested host and verify the peer identity before activation. | | 22 | Robot sample writes | The R2 `SpeedOverride` callback could target R1 state. Bind each callback to its own robot and apply that robot's write policy. | | 23 | AI result properties | Optional result children inherited remotely writable defaults. Preserve their declaration restrictions and keep server-produced results read-only. | | 24 | WSS peer-trust updates | Existing channels did not consistently respond to peer-trust changes. Revalidate complete chains for forward and reverse WSS channels and close only those that no longer validate. | | 25 | GDS revocation checks | Caller-provided certificates could cause issuer retrieval from embedded URLs. Resolve validation from configured local trust, issuers and CRLs without fetching caller-provided locations. | | 26 | GMS observations | Published correction observations retained writable model defaults. Reject remote writes while preserving publisher-side updates. | | 27 | Production-dataset monitoring | Subscription admission and delivery could use the reporter's context instead of each subscriber's permissions. Evaluate the subscriber at admission and on every delivery, including cancellation and disposal paths. | | 28 | WoT MQTT destinations | Hostless MQTT forms could imply a localhost destination before loopback checks. Reject them before credential lookup or connection attempts and validate the effective endpoint. | | 29 | XRegistry opaque reads | Content-ID reads and notifications did not consistently honor encrypted-read requirements or the currently accepted transport. Recheck the bound endpoint and reject transport downgrades before rebinding, including when protected values are already queued. | | 30 | PubSub capture dissection | Key loading did not consistently check the configured capture-root boundary. Check each actual key path immediately before opening it. | | 31 | PubSub publication queues | Imported WriterGroup queues were not fully checked against publish-topic grants. Check the destination actually used by each group. | | 32 | MCP PubSub mutations | A caller-supplied mode could select subscribe grants for an operation that publishes. Derive the required direction and parent context from the actual mutation. | | 33 | Generated write masks | Model import and generation could widen an explicit `UserWriteMask`. Preserve it through import, copy, merge, inheritance and dependency metadata. | | 34 | Generated method flags | An explicit `UserExecutable=false` could be lost. Preserve its specified state and resulting method behavior through the same model paths. | | 35 | Generated role identifiers | Unresolved role identifiers could cause permission entries to be omitted. Preserve namespace-qualified role IDs and report a diagnostic when a policy cannot be represented. | | 36 | Aggregation-server defaults | The `localhost` default did not reliably constrain listener binding. Use numeric loopback for the default endpoint and command-line fallback. | | 37 | Certificate-validation cache | Cached results could skip current leaf or issuer validity checks. Recheck validity windows on cache hits and keep hostname validation independent of cached chain trust. | | 38 | Software-update folders | Revision names and marker contents containing dot segments could select paths outside the software root. Normalize and check every derived path against that root. | | 39 | USDA arrays | Array elements were not encoded for their string or asset context. Escape them as data and preserve valid composition and round-trip behavior. | | 40 | Adapter hot reload | A changed dataset definition with the same name could keep old fields, subscriptions or cached values active. Compare complete immutable definitions and retire old state before replacement. | | 41 | Adapter index ranges | Subscription publication could ignore `IndexRange` and return the complete value. Preserve the requested projection and its status and timestamps; reject unsupported range providers. | | 42 | PubSub metadata and targets | Metadata from one connection could change another connection's target mapping. Scope metadata to its connection and provenance, and resolve targets using actual typed field IDs rather than field names or unintended positional fallback. | | 43 | Scales product locks | Product locks were not applied across every remote mutation path. Apply them consistently to value writes, filling calculations and related recipe/controller operations while retaining permitted updates. | | 44 | Event permission caching | Cached event decisions could outlive identity, role or node-policy changes. Revalidate the current subscriber, event type and source permissions instead of reusing a stale decision. | | 45 | WoT Modbus destinations | Hostless Modbus forms could imply localhost before loopback checks. Reject them before connecting and validate the effective endpoint. | | 46 | MCP local PKI tools | Peer-trust mutations did not consistently require the caller's `CertificateAdministration` grant. Check the capability before trust and untrust operations. | | 47 | Robot-control HTTP bridge | The bridge lacked consistent Host, Origin and port validation and caller-specific session grants. Apply those checks before borrowing the OPC UA session and retain supported local clients. | | 48 | Subscription transfers | Transferred subscriptions could retain cached notifications after the destination's permissions changed. Require authorization continuity between source and destination identities and roles, invalidate stale retransmission data when continuity fails, and preserve retransmission behavior for compatible transfers. | ### Compatibility notes - Replication uses the versioned NSRP v1 policy envelope. Upgrade peers together and re-encode or reseed legacy topology from the authoritative model; missing permission metadata is not inferred from old records. - Integrity-only DTLS peers must be upgraded together for the full sequence/epoch MAC. AEAD behavior is unchanged. - Distinct PubSub key groups require separate connections. - Custom sessions that cannot capture the actual encrypted transport, and subscription providers that cannot honor a requested range, are rejected rather than silently changing the requested behavior. - Subscription transfers retain retransmission data only when the destination authorization remains compatible with the source snapshot; otherwise the old sequence numbers and cached data are invalidated. ## Related Issues No separate tracking issue is linked. The individual corrections are listed above. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] 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. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [x] 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.
32 KiB
Transport Profiles
This document is the practical companion to 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
- Assembly layout
- Server-side configuration
- Client-side usage
- Discovery
- Opt-in: Kestrel-hosted
opc.tcp - Implementing a custom byte transport
- 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 |
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 |
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 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 IServerSessionBindingProvider to their listeners.
This read-only server lookup is separate from the client's
ISessionBindingProvider, which captures a generation-bound
dispatch client through CreateBindingAsync. Server hosts register custom lookup
implementations as IServerSessionBindingProvider; no shared interface combines
these capabilities.
The preview server lookup rename changes its public type identity: custom hosts
must update implementations and DI registrations and rebuild. It does not change
the lookup semantics or add members to existing session and transport interfaces.
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 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 foropc.tcp://.HttpsTransportChannel+HttpsTransportChannelFactory,OpcHttpsTransportChannelFactory— polymorphic client-side channel that handles bothhttps-uabinaryandhttps-uajsonbased on the endpoint'sTransportProfileUri.
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 forhttps://andopc.https://.WssTransportListenerFactory,OpcWssTransportListenerFactory— listener factories forwss://andopc.wss://.WebSocketClientByteTransport+WebSocketClientByteTransportFactory,WssTransportChannel,WssTransportChannelFactory,OpcWssTransportChannelFactory— client-side WSS+uacp.WssJsonTransportChannel,WssJsonTransportChannelFactory— client-side WSS+uajson (per-requestClientWebSocket).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.
<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.tcplistener uses the lightweight raw-socketTcpTransportListenerby default (no ASP.NET Core dependency). An opt-in alternative shipped inOpc.Ua.Bindings.Https(net8.0+) is described below. - The
opc.httpsandopc.wsslisteners both share a singleHttpsTransportListenerper(host, port)— Kestrel routes the inbound HTTP request to the right handler based on theUpgrade/Content-Typeheaders. Concretely:POSTwithContent-Type: application/octet-stream→https-uabinaryhandler.POSTwithContent-Type: application/opcua+uajson→https-uajsonhandler.- 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.
Binary WSS SecureChannels participate in application peer-trust revalidation.
A committed Peers TrustList or CRL change closes only channels whose complete
peer chain no longer validates, including outbound reverse connections, without
restarting the shared HTTPS host. Ordinary request-scoped HTTPS remains governed
by its TLS validation rather than this application-channel sweep.
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:
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 towss://(the IETF scheme) and falls back toUtils.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 freshClientWebSocketper 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
HttpClientper channel; the encoding is selected fromEndpointDescription.TransportProfileUriat 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 (
EndpointDescriptionemission via the sharedTcpServiceHostbase).
Swap it in by registering the factory through the DI container
before opening any opc.tcp listener (typically at application
startup):
// 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-socketopc.tcplistener and channel factories into the transport binding registry, so clients and servers reachopc.tcp://without it. Call it only to be explicit; theAdd*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.
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.Httpsoutside the UASC pipeline.
Contract summary
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
ReceiveChunkAsyncare rented from the suppliedBufferManager; the caller (the UASC channel) returns them viaBufferManager.ReturnBufferonce 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 treatClose()as a no-op when already closed. - Cancellation.
ReceiveChunkAsyncshould 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
ServiceResultExceptionwith the matchingStatusCodes.BadXxx(BadConnectionClosed,BadTcpMessageTypeInvalid,BadTcpMessageTooLarge, …) so the channel can route them through normal UA fault paths. - Vectored Send. The
BufferCollectionoverload 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 whatWebSocketByteTransportBasedoes). - Client vs server. Client transports implement
ConnectAsyncto dial outbound; server transports built from an already-accepted connection should throwNotSupportedExceptionfromConnectAsync(this is the contract enforced byUaSCUaBinaryClientChannel).
Wiring the transport into the channel pipeline
Client side
Implement IUaSCByteTransportFactory
and hand the factory to a subclass of
UaSCUaBinaryTransportChannel:
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
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:
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
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.
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— supported profiles, security policies, message encodings.MigrationGuide.md— note on the breaking removal ofIMessageSocketand the newIUaSCByteTransportcontract.- OPC UA Part 6 §7.4 (HTTPS) / §7.5 (WebSockets) for the wire-format specification.