Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5530e2d
feat(client-core): derive host leaf type conversions; drop hand-writt…
claude Jun 17, 2026
c9af412
feat(macros,psrp): add #[ps(nil_when_none)]; derive CommandParameter
claude Jun 17, 2026
507e315
feat(psrp,client-core): typed RemoteHostMethodId; derive host call me…
claude Jun 17, 2026
1c21136
feat: derive host RESPONSE messages with typed RemoteHostMethodId
claude Jun 17, 2026
082e04b
feat(macros,psrp): add #[ps(to_string)]; derive Command + PowerShellP…
claude Jun 17, 2026
fe1fd00
feat(psrp): derive PROGRESS_RECORD message + ProgressRecordType enum
claude Jun 17, 2026
712a64c
feat(psrp): BTreeMap<String,PsValue> <-> PSPrimitiveDictionary; deriv…
claude Jun 17, 2026
0992a23
feat(macros,psrp): add #[ps(dictionary)] + #[ps(flatten)]; derive App…
claude Jun 17, 2026
3c8fa71
feat(macros,psrp): add #[ps(value_dictionary)] + #[ps(wrap)]; derive …
claude Jun 17, 2026
67a767a
feat(psrp): derive CreatePipeline + InitRunspacePool messages
claude Jun 17, 2026
e7ca729
feat(macros,psrp): derive ErrorRecord; add flatten_prefix/fallback_ob…
claude Jun 18, 2026
d7a1ac4
feat(macros,psrp): derive InformationRecord; add PsUnion for polymorp…
claude Jun 18, 2026
b08b26a
refactor(macros): harden value_dictionary (honor with) and PsUnion (r…
claude Jun 18, 2026
1722cba
fix(macros): treat present-but-Nil property as absent in PsDeserialize
irvingoujAtDevolution Jun 23, 2026
67051f2
feat(winrm): spec-correct HTTP/HTTPS transport with connection-orient…
irvingoujAtDevolution Jun 23, 2026
780bc9b
test(e2e): transport x auth x sealing matrix + auth-aware test scaffo…
irvingoujAtDevolution Jun 23, 2026
afcb764
Merge pull request #24 from Devolutions/stack/05-transport-auth-matrix
irvingoujAtDevolution Jun 23, 2026
f3b1f6f
Merge pull request #23 from Devolutions/stack/04-winrm-transport-auth
irvingoujAtDevolution Jun 23, 2026
df73713
Merge pull request #22 from Devolutions/stack/03-writeprogress-nil
irvingoujAtDevolution Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 13 additions & 5 deletions crates/ironposh-async/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::Arc;

use anyhow::Context;
use futures::{SinkExt, StreamExt, channel::mpsc, join};
use futures::{SinkExt, StreamExt, channel::mpsc, join, try_join};
use ironposh_client_core::{
connector::{
Connector, ConnectorStepResult, UserOperation, WinRmConfig, active_session::UserEvent,
Expand Down Expand Up @@ -302,9 +302,13 @@ where
);

let joined_task = async move {
let res = join!(active_session_task, multiplex_pipeline_task);
// try_join! short-circuits the moment either task errors (e.g. a failed
// handshake in the active session) instead of waiting for the multiplexer,
// which would otherwise block forever on its channels and hang the whole
// connection task — and with it any caller awaiting this future.
let res = try_join!(active_session_task, multiplex_pipeline_task);
let _ = session_event_tx_2.unbounded_send(crate::SessionEvent::Closed);
res.0.and(res.1)
res.map(|_| ())
};

(
Expand Down Expand Up @@ -386,9 +390,13 @@ where
build_pipeline_multiplexer(user_input_tx, server_output_rx, pipeline_input_rx, "Serial");

let joined_task = async move {
let res = join!(active_session_task, multiplex_pipeline_task);
// try_join! short-circuits the moment either task errors (e.g. a failed
// handshake in the active session) instead of waiting for the multiplexer,
// which would otherwise block forever on its channels and hang the whole
// connection task — and with it any caller awaiting this future.
let res = try_join!(active_session_task, multiplex_pipeline_task);
let _ = session_event_tx_2.unbounded_send(crate::SessionEvent::Closed);
res.0.and(res.1)
res.map(|_| ())
};

(
Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-async/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ mod tests {
status_code: 200,
headers: vec![],
body: HttpBody::Xml(xml),
peer_cert_der: None,
},
conn_id,
None,
Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-async/src/session_serial/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,7 @@ mod tests {
status_code: 200,
headers: vec![],
body: ironposh_client_core::connector::http::HttpBody::Xml(String::new()),
peer_cert_der: None,
},
ConnectionId::test_new(1),
None,
Expand Down
2 changes: 2 additions & 0 deletions crates/ironposh-client-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ thiserror = "2.0.12"
uuid = { version = "1.17.0", features = ["v4"] }
ironposh-winrm = { path = "../ironposh-winrm" }
ironposh-psrp = { path = "../ironposh-psrp" }
ironposh-macros = { path = "../ironposh-macros" }
ironposh-xml = { path = "../ironposh-xml" }
typed-builder = "0.21.0"
base64 = "0.22.1"
Expand All @@ -17,6 +18,7 @@ rand = "0.8"
aes = "0.9.0-rc.1"
cbc = "0.2.0-rc.1"
cipher = "0.5.0-rc.1"
sha2 = "0.11.0-rc.2"
tracing = "0.1.41"
url = "2.5.7"
whoami = "1.6.1"
Expand Down
46 changes: 17 additions & 29 deletions crates/ironposh-client-core/src/connector/active_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ pub enum UserOperation {
CancelHostCall {
scope: HostCallScope,
call_id: i64,
/// The host method being cancelled (echoed back as `mi`).
method: ironposh_psrp::RemoteHostMethodId,
reason: Option<String>,
},
/// disconnect the runspace pool shell (MS-WSMV Disconnect)
Expand Down Expand Up @@ -318,15 +320,13 @@ impl ActiveSession {
HostCallScope::Pipeline { command_id } => self.send_pipeline_host_response(
command_id,
response.call_id,
response.method_id,
response.method_name,
response.method,
response.method_result,
response.method_exception,
),
HostCallScope::RunspacePool => self.send_runspace_pool_host_response(
response.call_id,
response.method_id,
response.method_name,
response.method,
response.method_result,
response.method_exception,
),
Expand All @@ -341,28 +341,20 @@ impl ActiveSession {
UserOperation::CancelHostCall {
scope,
call_id,
method,
reason: _,
} => {
// send an error response back
let err = Some(PsValue::Primitive(PsPrimitiveValue::Str(format!(
"Host call {call_id} was cancelled"
))));
match scope {
HostCallScope::Pipeline { command_id } => self.send_pipeline_host_response(
command_id,
call_id,
0,
"Cancelled".to_string(),
None,
err,
),
HostCallScope::RunspacePool => self.send_runspace_pool_host_response(
call_id,
0,
"Cancelled".to_string(),
None,
err,
),
HostCallScope::Pipeline { command_id } => {
self.send_pipeline_host_response(command_id, call_id, method, None, err)
}
HostCallScope::RunspacePool => {
self.send_runspace_pool_host_response(call_id, method, None, err)
}
}
}

Expand Down Expand Up @@ -826,13 +818,12 @@ impl ActiveSession {
}

/// Build + send a pipeline host response, then queue a receive for that pipeline.
#[instrument(skip(self, result, error), fields(command_id = %command_id, call_id, method_name = %method_name))]
#[instrument(skip(self, result, error), fields(command_id = %command_id, call_id, method = ?method))]
fn send_pipeline_host_response(
&mut self,
command_id: uuid::Uuid,
call_id: i64,
method_id: i32,
method_name: String,
method: ironposh_psrp::RemoteHostMethodId,
result: Option<PsValue>,
error: Option<PsValue>,
) -> Result<ActiveSessionOutput, crate::PwshCoreError> {
Expand All @@ -856,8 +847,7 @@ impl ActiveSession {
info!("building pipeline host response");
let host_resp = PipelineHostResponse::builder()
.call_id(call_id)
.method_id(method_id)
.method_name(method_name)
.method(method)
.method_result_opt(result)
.method_exception_opt(error)
.build();
Expand All @@ -880,12 +870,11 @@ impl ActiveSession {
}

/// Build + send a runspace-pool host response, then queue a receive for pool streams.
#[instrument(skip(self, result, error), fields(call_id, method_name = %method_name))]
#[instrument(skip(self, result, error), fields(call_id, method = ?method))]
fn send_runspace_pool_host_response(
&mut self,
call_id: i64,
method_id: i32,
method_name: String,
method: ironposh_psrp::RemoteHostMethodId,
result: Option<PsValue>,
error: Option<PsValue>,
) -> Result<ActiveSessionOutput, crate::PwshCoreError> {
Expand All @@ -909,8 +898,7 @@ impl ActiveSession {
info!("building runspace pool host response");
let host_resp = RunspacePoolHostResponse::builder()
.call_id(call_id)
.method_id(method_id)
.method_name(method_name)
.method(method)
.method_result_opt(result)
.method_exception_opt(error)
.build();
Expand Down
53 changes: 41 additions & 12 deletions crates/ironposh-client-core/src/connector/auth_sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,19 @@ impl<'ctx> SecurityContextBuilderHolder<'ctx> {
}

impl SspiAuthContext {
pub fn new(sspi_config: SspiAuthConfig) -> Result<Self, crate::PwshCoreError> {
pub fn new(
sspi_config: SspiAuthConfig,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, crate::PwshCoreError> {
match sspi_config {
SspiAuthConfig::NTLM {
identity,
target: target_name,
} => SspiContext::new_ntlm(identity, SspiConfig::new(target_name))
.map(SspiAuthContext::Ntlm),
} => SspiContext::new_ntlm(
identity,
SspiConfig::with_channel_binding(target_name, channel_binding),
)
.map(SspiAuthContext::Ntlm),

SspiAuthConfig::Kerberos {
identity,
Expand All @@ -79,7 +85,7 @@ impl SspiAuthContext {
} => SspiContext::new_kerberos(
identity,
kerberos_config.into(),
SspiConfig::new(target_name),
SspiConfig::with_channel_binding(target_name, channel_binding),
)
.map(SspiAuthContext::Kerberos),

Expand All @@ -88,7 +94,7 @@ impl SspiAuthContext {
kerberos_config,
target: target_name,
} => {
let sspi_config = SspiConfig::new(target_name);
let sspi_config = SspiConfig::with_channel_binding(target_name, channel_binding);

let client_computer_name = whoami::fallible::hostname().map_err(|e| {
crate::PwshCoreError::InternalError(format!(
Expand Down Expand Up @@ -162,8 +168,9 @@ impl SspiAuthSequence {
sspi_auth_config: SspiAuthConfig,
require_encryption: bool,
http_builder: HttpBuilder,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, crate::PwshCoreError> {
let context = SspiAuthContext::new(sspi_auth_config)?;
let context = SspiAuthContext::new(sspi_auth_config, channel_binding)?;
Ok(Self {
context,
http_builder,
Expand Down Expand Up @@ -209,9 +216,17 @@ impl SspiAuthSequence {
SspiAuthenticator::resume(generator_holder, kdc_response)
}

/// Drive the next SSPI leg. `operation_body` is `Some` only when sealing is
/// off (HTTPS): in that mode the auth token has no sealed body to ride in, and
/// the server (HTTP.SYS) re-challenges a token-less operation request and
/// closes the connection — so we attach the actual operation SOAP to each
/// challenge leg (RFC 4559 / requests-kerberos `force_preemptive` style). The
/// leg that finally authenticates therefore also carries (and the server
/// processes) the operation, and its 200 is the operation response.
pub(crate) fn process_initialized_sec_context(
&mut self,
sec_context: &crate::connector::authenticator::SecContextInit,
operation_body: Option<&str>,
) -> Result<SecCtxInited, PwshCoreError> {
let res = match &mut self.context {
SspiAuthContext::Ntlm(auth_context) => {
Expand All @@ -228,14 +243,20 @@ impl SspiAuthSequence {
match res {
super::authenticator::ActionReqired::TryInitSecContextAgain { token } => {
self.http_builder.with_auth_header(token.0);
Ok(SecCtxInited::Continue(
self.http_builder.post(HttpBody::empty()),
))
let body =
operation_body.map_or_else(HttpBody::empty, |xml| HttpBody::Xml(xml.to_owned()));
Ok(SecCtxInited::Continue(self.http_builder.post(body)))
}
super::authenticator::ActionReqired::Done { token } => Ok(SecCtxInited::Done(token)),
}
}

/// Whether SSPI message sealing is in effect (HTTP). When false (HTTPS),
/// operation auth must ride the challenge legs instead of a sealed body.
pub(crate) fn require_encryption(&self) -> bool {
self.require_encryption
}

pub fn when_finish(self) -> Authenticated {
let Self {
context,
Expand Down Expand Up @@ -312,11 +333,19 @@ impl BasicAuthSequence {
}

impl AuthSequence {
pub fn new(cfg: &AuthSequenceConfig, http: HttpBuilder) -> Result<Self, PwshCoreError> {
pub fn new(
cfg: &AuthSequenceConfig,
http: HttpBuilder,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, PwshCoreError> {
match &cfg.authenticator_config {
AuthenticatorConfig::Sspi(sspi) => {
let sspi_auth =
SspiAuthSequence::new(sspi.clone(), cfg.require_sspi_sealing, http)?;
let sspi_auth = SspiAuthSequence::new(
sspi.clone(),
cfg.require_sspi_sealing,
http,
channel_binding,
)?;
Ok(Self::Sspi(sspi_auth))
}
AuthenticatorConfig::Basic { username, password } => {
Expand Down
Loading