Skip to content

feat(pdu): add Initiate Multitransport Request/Response PDU types - #1091

Merged
Benoît Cortier (CBenoit) merged 1 commit into
Devolutions:masterfrom
lamco-admin:glamberson/multitransport-pdus
Feb 12, 2026
Merged

Benoît Cortier (CBenoit) merged 1 commit into
Devolutions:masterfrom
lamco-admin:glamberson/multitransport-pdus

Conversation

@glamberson

Copy link
Copy Markdown
Contributor

Add MultitransportRequestPdu and MultitransportResponsePdu types for the
sideband UDP transport bootstrapping PDUs defined in [MS-RDPBCGR] 2.2.15.1
and 2.2.15.2. Needed to decode/encode the IO channel messages that initiate
UDP transport setup.

See #140 for context.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@CBenoit
Benoît Cortier (CBenoit) merged commit 5a50f40 into Devolutions:master Feb 12, 2026
10 checks passed
@glamberson
Greg Lamberson (glamberson) deleted the glamberson/multitransport-pdus branch March 17, 2026 12:12
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Jul 31, 2026
…1098)

## Summary

Makes the `MultitransportBootstrapping` state functional instead of a
no-op
pass-through. After licensing the server may send 0, 1, or 2 Initiate
Multitransport Request PDUs before capabilities exchange. Each one is
surfaced
to the application, which establishes UDP transport (RDPEUDP2 + TLS +
RDPEMT)
or declines, and the connector reports the outcome back to the server.

## API

Mirrors the existing `should_perform_X()` pause-point pattern used by
TLS
upgrade and CredSSP, but uses `complete_X()` / `skip_X()` rather than
`mark_X_as_done()` because completion carries result data:

- `should_perform_multitransport()`: true while a request awaits an
outcome
- `multitransport_request()`: the request awaiting an outcome, or `None`
- `complete_multitransport(result, output)`: report the outcome, resume
- `skip_multitransport(output)`: decline, resume

`complete_multitransport` accepts a `MultitransportResult` (a `Success`
/
`Failure(hresult)` enum) rather than a caller-built response PDU. The
connector
builds the response internally from the stored request ID.

Requests are surfaced one at a time rather than as a batch. There is no
end
marker for the set, and MS-RDPBCGR 3.2.5.15.1 requires the client to act
on a
request as soon as it decodes one, so waiting to learn how many are
coming is
not an option the protocol offers. `should_perform_multitransport()` can
therefore come round twice; the caller answers reliable and lossy
separately.

## Approach

**Routing.** Requests arrive on the negotiated MCS message channel
(2.2.15.1) and the Demand Active on the I/O channel, so the channel
decides
which is which. The message channel also carries NetworkAutoDetect since
#1348,
so a decode still confirms what arrived there, but the I/O channel is
never
speculatively decoded as multitransport. A PDU on neither channel is an
error.

For the decode to be a sound confirmation the request decoder must
reject a
Demand Active, so this PR also tightens `MultitransportRequestPdu` to
require
the exact `SEC_TRANSPORT_REQ` security-header flag.

**Yielding.** Each request is surfaced the moment it decodes. Responding
returns the connector to `MultitransportBootstrapping` to read whatever
comes
next, which may be a second request or the Demand Active. Nothing is
buffered
and nothing is replayed: when the request is surfaced the Demand Active
has not
arrived yet.

**Soft-Sync.** The Initiate Multitransport Response is the Soft-Sync
signalling path (2.2.15.2), permitted only when both peers advertised
`SOFTSYNC_TCP_TO_UDP` in their GCC `MultiTransportChannelData`. The
server's
block is retained from the GCC exchange and checked against the client's
configured flags. One rule covers both paths:

- Soft-Sync negotiated: always respond, `S_OK` or `E_ABORT`, including
on
  `skip_multitransport()`, which 3.2.5.15.1 requires. Both the async and
blocking drivers skip automatically, so without this every default
client
  leaves a compliant server waiting.
- Not negotiated: never respond. The outcome is reported in band on the
new
transport, and putting anything on the main channel would be the
violation.

The response goes on the message channel per 2.2.15.2 and 3.2.5.15.2. If
Soft-Sync was negotiated but no message channel exists the connector
errors
rather than falling back to the I/O channel, and that check runs before
the
pending state is taken, so the caller is left with a connector it can
still
inspect or decline from.

## Wire behaviour

On the wire TCP and UDP negotiation happen in parallel: the UDP
transport is
established alongside the ongoing TCP handshake, and its completion
signals the
dynamic-channel layer that subsequent channels may migrate to UDP. The
connector's API yield point here is a Rust affordance, not a
spec-mandated TCP
pause. Thanks to @hardening for the correction.

## Tests

Connector state-machine tests in `ironrdp-testsuite-core` drive the
public API
with the shared `SERVER_DEMAND_ACTIVE` fixture:

- a request is surfaced on arrival, without waiting for a following PDU
  (regression test for the stall);
- responding returns to bootstrapping so a second request is read
normally;
- a third request is rejected per the 2.2.15.1 cap;
- a Demand Active on the I/O channel ends bootstrapping;
- the response targets the message channel, decoded back off the wire;
- a `Failure` result is carried through;
- `skip` sends `E_ABORT` under Soft-Sync, and nothing without it;
- `complete` emits nothing without Soft-Sync but still resumes;
- a failed response leaves the connector in `MultitransportPending`,
still able
  to report or decline, rather than `Consumed`;
- `complete` / `skip` outside `MultitransportPending` error;
- a Demand Active's user data does not decode as a
`MultitransportRequestPdu`
  (regression test for the decoder tightening above).

Fuzzing: `MultitransportRequestPdu` and `MultitransportResponsePdu` are
added
to the `pdu_decode` and `pdu_round_trip` oracles, so the decode surface
is
fuzzed against arbitrary input (auto-discovered into the fuzz CI
matrix). Both
targets smoke-run clean.

Full end-to-end coverage against a live server still awaits server-side
`MultitransportRequestPdu` emission, which `ironrdp::server::RdpServer`
does
not implement yet.

## Breaking changes

`cargo semver-checks` reports three, so the title carries the marker:

- `ClientConnector` gains the public field
`server_multitransport_flags`, so struct literals need updating
(`constructible_struct_adds_field`).
- `ClientConnectorState::MultitransportBootstrapping` gains
`message_channel_id` and `requests_seen`
(`enum_struct_variant_field_added`).
- Three `ClientConnectorState` discriminants shift, because the new
variants are inserted mid-enum
(`enum_no_repr_variant_discriminant_changed`).

The third I would call a false positive worth naming rather than
designing around: `ClientConnectorState` is `#[non_exhaustive]`, has no
`repr`, and every variant carries fields, so there is no `as` cast a
downstream crate could have depended on. The first two are real.

## Verification

- `cargo xtask check fmt -v` green
- `cargo xtask check lints -v` green
- `cargo xtask check tests -v` green
- `cargo xtask check typos -v` green
- `cargo xtask check locks -v` green

## References

Builds on: #1091
Related: #140, #1348

---------

Signed-off-by: Greg Lamberson <greg@lamco.io>
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Aug 2, 2026
…1098)

Makes the `MultitransportBootstrapping` state functional instead of a
no-op
pass-through. After licensing the server may send 0, 1, or 2 Initiate
Multitransport Request PDUs before capabilities exchange. Each one is
surfaced
to the application, which establishes UDP transport (RDPEUDP2 + TLS +
RDPEMT)
or declines, and the connector reports the outcome back to the server.

Mirrors the existing `should_perform_X()` pause-point pattern used by
TLS
upgrade and CredSSP, but uses `complete_X()` / `skip_X()` rather than
`mark_X_as_done()` because completion carries result data:

- `should_perform_multitransport()`: true while a request awaits an
outcome
- `multitransport_request()`: the request awaiting an outcome, or `None`
- `complete_multitransport(result, output)`: report the outcome, resume
- `skip_multitransport(output)`: decline, resume

`complete_multitransport` accepts a `MultitransportResult` (a `Success`
/
`Failure(hresult)` enum) rather than a caller-built response PDU. The
connector
builds the response internally from the stored request ID.

Requests are surfaced one at a time rather than as a batch. There is no
end
marker for the set, and MS-RDPBCGR 3.2.5.15.1 requires the client to act
on a
request as soon as it decodes one, so waiting to learn how many are
coming is
not an option the protocol offers. `should_perform_multitransport()` can
therefore come round twice; the caller answers reliable and lossy
separately.

**Routing.** Requests arrive on the negotiated MCS message channel
(2.2.15.1) and the Demand Active on the I/O channel, so the channel
decides
which is which. The message channel also carries NetworkAutoDetect since
so a decode still confirms what arrived there, but the I/O channel is
never
speculatively decoded as multitransport. A PDU on neither channel is an
error.

For the decode to be a sound confirmation the request decoder must
reject a
Demand Active, so this PR also tightens `MultitransportRequestPdu` to
require
the exact `SEC_TRANSPORT_REQ` security-header flag.

**Yielding.** Each request is surfaced the moment it decodes. Responding
returns the connector to `MultitransportBootstrapping` to read whatever
comes
next, which may be a second request or the Demand Active. Nothing is
buffered
and nothing is replayed: when the request is surfaced the Demand Active
has not
arrived yet.

**Soft-Sync.** The Initiate Multitransport Response is the Soft-Sync
signalling path (2.2.15.2), permitted only when both peers advertised
`SOFTSYNC_TCP_TO_UDP` in their GCC `MultiTransportChannelData`. The
server's
block is retained from the GCC exchange and checked against the client's
configured flags. One rule covers both paths:

- Soft-Sync negotiated: always respond, `S_OK` or `E_ABORT`, including
on
  `skip_multitransport()`, which 3.2.5.15.1 requires. Both the async and
blocking drivers skip automatically, so without this every default
client
  leaves a compliant server waiting.
- Not negotiated: never respond. The outcome is reported in band on the
new
transport, and putting anything on the main channel would be the
violation.

The response goes on the message channel per 2.2.15.2 and 3.2.5.15.2. If
Soft-Sync was negotiated but no message channel exists the connector
errors
rather than falling back to the I/O channel, and that check runs before
the
pending state is taken, so the caller is left with a connector it can
still
inspect or decline from.

On the wire TCP and UDP negotiation happen in parallel: the UDP
transport is
established alongside the ongoing TCP handshake, and its completion
signals the
dynamic-channel layer that subsequent channels may migrate to UDP. The
connector's API yield point here is a Rust affordance, not a
spec-mandated TCP
pause. Thanks to @hardening for the correction.

Connector state-machine tests in `ironrdp-testsuite-core` drive the
public API
with the shared `SERVER_DEMAND_ACTIVE` fixture:

- a request is surfaced on arrival, without waiting for a following PDU
  (regression test for the stall);
- responding returns to bootstrapping so a second request is read
normally;
- a third request is rejected per the 2.2.15.1 cap;
- a Demand Active on the I/O channel ends bootstrapping;
- the response targets the message channel, decoded back off the wire;
- a `Failure` result is carried through;
- `skip` sends `E_ABORT` under Soft-Sync, and nothing without it;
- `complete` emits nothing without Soft-Sync but still resumes;
- a failed response leaves the connector in `MultitransportPending`,
still able
  to report or decline, rather than `Consumed`;
- `complete` / `skip` outside `MultitransportPending` error;
- a Demand Active's user data does not decode as a
`MultitransportRequestPdu`
  (regression test for the decoder tightening above).

Fuzzing: `MultitransportRequestPdu` and `MultitransportResponsePdu` are
added
to the `pdu_decode` and `pdu_round_trip` oracles, so the decode surface
is
fuzzed against arbitrary input (auto-discovered into the fuzz CI
matrix). Both
targets smoke-run clean.

Full end-to-end coverage against a live server still awaits server-side
`MultitransportRequestPdu` emission, which `ironrdp::server::RdpServer`
does
not implement yet.

`cargo semver-checks` reports three, so the title carries the marker:

- `ClientConnector` gains the public field
`server_multitransport_flags`, so struct literals need updating
(`constructible_struct_adds_field`).
- `ClientConnectorState::MultitransportBootstrapping` gains
`message_channel_id` and `requests_seen`
(`enum_struct_variant_field_added`).
- Three `ClientConnectorState` discriminants shift, because the new
variants are inserted mid-enum
(`enum_no_repr_variant_discriminant_changed`).

The third I would call a false positive worth naming rather than
designing around: `ClientConnectorState` is `#[non_exhaustive]`, has no
`repr`, and every variant carries fields, so there is no `as` cast a
downstream crate could have depended on. The first two are real.

- `cargo xtask check fmt -v` green
- `cargo xtask check lints -v` green
- `cargo xtask check tests -v` green
- `cargo xtask check typos -v` green
- `cargo xtask check locks -v` green

Builds on: #1091
Related: #140, #1348

---------

Signed-off-by: Greg Lamberson <greg@lamco.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants