Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions Cargo.lock

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

54 changes: 46 additions & 8 deletions crates/ironrdp-activex/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2394,15 +2394,19 @@ enum ActiveXTransport {
RDCleanPath(RDCleanPathConfig),
}

fn active_x_transport_from_client_transport(transport: &Transport) -> ActiveXTransport {
fn active_x_transport_from_client_transport(
transport: &Transport,
) -> core::result::Result<ActiveXTransport, &'static str> {
match transport {
Transport::Direct => ActiveXTransport::Direct,
Transport::Gateway(gateway) => ActiveXTransport::Gateway {
Transport::Direct => Ok(ActiveXTransport::Direct),
Transport::Gateway(gateway) => Ok(ActiveXTransport::Gateway {
endpoint: gateway.endpoint.clone(),
username: gateway.username.clone(),
password: gateway.password.clone(),
},
Transport::RDCleanPath(rdcleanpath) => ActiveXTransport::RDCleanPath(rdcleanpath.clone()),
}),
Transport::RDCleanPath(rdcleanpath) => Ok(ActiveXTransport::RDCleanPath(rdcleanpath.clone())),
// Named-pipe RDP (e.g. Windows Sandbox) is agent/desktop-client only.
Transport::NamedPipe { .. } => Err("Windows named-pipe transport is not supported by the ActiveX host"),
}
}

Expand Down Expand Up @@ -6604,14 +6608,21 @@ impl Control {
"RDCleanPath URL must use the ws or wss scheme",
);
}
let rpc_transport = match active_x_transport_from_client_transport(config.transport()) {
Ok(transport) => transport,
Err(message) => {
return ironrdp_agent::ipc::Response::typed_error(
ironrdp_agent::ipc::AgentErrorCategory::InvalidRequest,
message,
);
}
};
let Credentials::UsernamePassword { username, password } = &config.connector().credentials else {
return ironrdp_agent::ipc::Response::typed_error(
ironrdp_agent::ipc::AgentErrorCategory::InvalidRequest,
"smart card credentials are not supported by the ActiveX host",
);
};

let rpc_transport = active_x_transport_from_client_transport(config.transport());
{
let mut settings = self.settings.borrow_mut();
settings.server = config.destination().name().to_owned();
Expand Down Expand Up @@ -6683,6 +6694,10 @@ impl Control {
compatibility.gateway_usage_method = GatewayUsageMethod::Direct.as_i64() as u32;
compatibility.gateway_creds_source = GatewayCredentialsSource::UseServerCredentials.as_i64() as u32;
}
// Rejected by `active_x_transport_from_client_transport` before settings apply.
Transport::NamedPipe { .. } => {
unreachable!("NamedPipe must fail RPC connect before compatibility settings")
}
}
}

Expand Down Expand Up @@ -12299,14 +12314,37 @@ mod tests {
.build()
.expect("ActiveX RDCleanPath configuration is valid");

let ActiveXTransport::RDCleanPath(rdcleanpath) = active_x_transport_from_client_transport(config.transport())
let ActiveXTransport::RDCleanPath(rdcleanpath) =
active_x_transport_from_client_transport(config.transport()).expect("RDCleanPath maps")
else {
panic!("client RDCleanPath transport must be retained");
};
assert_eq!(rdcleanpath.url.as_str(), "wss://rdcleanpath.example.test/rdp");
assert_eq!(rdcleanpath.auth_token, "test-token");
}

#[test]
fn activex_rejects_named_pipe_transport_mapping() {
let config = ConfigBuilder::new()
.with_destination(Destination::from_parts("sandbox", 3389))
.with_username("user")
.with_password("password")
.with_client_build(10_000)
.with_client_dir("C:\\")
.with_client_name("IronRDP ActiveX")
.with_platform(MajorPlatformType::WINDOWS)
.with_transport(TransportKind::NamedPipe {
path: r"\\.\pipe\test".into(),
})
.build()
.expect("NamedPipe configuration is valid for client builders");

assert!(matches!(
active_x_transport_from_client_transport(config.transport()),
Err("Windows named-pipe transport is not supported by the ActiveX host")
));
}

#[test]
fn activex_rpc_rdcleanpath_configuration_uses_activex_property_names() {
let mut properties = PropertySet::new();
Expand Down
16 changes: 15 additions & 1 deletion crates/ironrdp-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,28 @@ ironrdp-daemon = { path = "../ironrdp-daemon", version = "0.1" }
ironrdp-rpc = { path = "../ironrdp-rpc", version = "0.1" }

# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "io-util", "rt"] }

# CLI
clap = { version = "4.6", features = ["derive", "cargo", "env"] }

# Utils
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[target.'cfg(windows)'.dependencies]
# Windows Sandbox control plane: gRPC/HTTP2 over a per-user named pipe.
bytes = "1"
h2 = "0.4"
http = "1"
md-5 = "0.10"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_System_Threading",
] }

[lints]
workspace = true
34 changes: 34 additions & 0 deletions crates/ironrdp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,40 @@ connection flags. Explicit flags override those process-local values. The native
these only when it is explicitly enabled; `RDP_AUTOLOGON` is active only when its value is exactly
`1`, and requires nonempty username and password values.

## Windows Sandbox

On Windows with the Windows Sandbox feature enabled, the agent can attach to a sandbox that was
created separately (preferred) and speak RDP over the product's default **named-pipe** transport
(`\\.\pipe\{VMId}`), using standard RDP security with no encryption (`PROTOCOL_RDP` /
`ENCRYPTION_LEVEL_NONE`).

```bat
:: create headless (prints Id only)
wsb start

:: inspect / list via WindowsSandboxServer gRPC
ironrdp-agent sandbox list
ironrdp-agent sandbox config <sandbox-id>

:: connect (daemon must already be running)
ironrdp-agent daemon-start
ironrdp-agent connect --sandbox-id <sandbox-id>
ironrdp-agent screenshot sandbox.png
```

The agent speaks `sandboxserver.SandboxCore` in-process over the per-user named pipe
(`\\.\pipe\wsandbox\<md5(user SID)>`) — no .NET helper is required. WindowsSandboxServer must
already be running (starting a sandbox with `wsb start` / the Sandbox UI is enough).

Low-level escape hatch when you already have the pipe path and guest password:

```bat
ironrdp-agent connect --sandbox-pipe \\.\pipe\{VMId} -u WDAGUtilityAccount -p <password>
```

`Local` (VMConnect `:2179` + PCB) and guest TCP `:3389` transports are not implemented as the
primary path; use the default NamedPipe recipe.

## Prebuilt binaries

Prebuilt, checksummed archives are attached to each GitHub Release under the `ironrdp-agent-v*`
Expand Down
99 changes: 99 additions & 0 deletions crates/ironrdp-agent/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ enum Command {
},
/// Execute commands over the session's NOW DVC endpoint.
Now(NowArgs),
/// Windows Sandbox lifecycle helpers (list/config/stop via WindowsSandboxServer gRPC).
#[cfg(windows)]
Sandbox(SandboxArgs),
}

#[cfg(windows)]
#[derive(Args, Debug)]
struct SandboxArgs {
#[command(subcommand)]
command: SandboxCommand,
}

#[cfg(windows)]
#[derive(Subcommand, Debug)]
enum SandboxCommand {
/// List running sandbox ids (`EnumerateSandboxVMs`).
List,
/// Show RDP config for a sandbox (password redacted).
Config {
/// Sandbox id from `wsb start` / `sandbox list`.
id: String,
},
/// Shut down a running sandbox (`ShutdownSandbox`).
Stop { id: String },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
Expand Down Expand Up @@ -313,6 +337,16 @@ struct ConnectArgs {
/// verbosity up-front when troubleshooting a connection.
#[arg(long)]
log_directive: Option<String>,
/// Connect to a running Windows Sandbox by id (fetches pipe path + creds via gRPC).
/// Prefer creating the VM with `wsb start` first.
#[cfg(windows)]
#[arg(long, value_name = "GUID", conflicts_with_all = ["server", "sandbox_pipe"])]
sandbox_id: Option<String>,
/// Low-level escape hatch: connect over a Windows Sandbox named pipe path
/// (`\\.\pipe\{VMId}` or bare VMId). Requires `--username` / `--password`.
#[cfg(windows)]
#[arg(long, value_name = "PIPE", conflicts_with = "server")]
sandbox_pipe: Option<String>,
Comment thread
mamoreau-devolutions marked this conversation as resolved.
Resolved
}

#[derive(Args, Debug)]
Expand Down Expand Up @@ -464,6 +498,10 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
return Ok(());
}
Command::Connect(args) => build_connect_request(args)?,
#[cfg(windows)]
Command::Sandbox(args) => {
return run_sandbox_command(args);
}
Command::Disconnect => Request::Disconnect,
Command::Status => Request::Status,
Command::QueryProps(args) => Request::QueryProps {
Expand Down Expand Up @@ -554,12 +592,73 @@ fn build_connect_request(args: ConnectArgs) -> anyhow::Result<Request> {
properties.set_domain(domain);
}

#[cfg(windows)]
{
// Sandbox defaults are the base; explicit .rdp / --prop / named flags win on conflict.
// Transport/security invariants from the sandbox path are re-applied last so a file
// cannot force TLS/CredSSP onto a NamedPipe session.
if let Some(sandbox_id) = args.sandbox_id {
let sandbox_props =
crate::sandbox::properties_for_sandbox_id(&sandbox_id).context("resolve Windows Sandbox RDP config")?;
let mut merged = sandbox_props;
merged.merge(&properties);
crate::sandbox::reassert_named_pipe_security(&mut merged);
properties = merged;
} else if let Some(pipe) = args.sandbox_pipe {
let user = properties
.username()
.map(str::to_owned)
.context("--sandbox-pipe requires --username (or username in the .rdp file)")?;
let pass = properties
.clear_text_password()
.map(str::to_owned)
.context("--sandbox-pipe requires --password (or ClearTextPassword in the .rdp file)")?;
let mut merged = crate::sandbox::properties_for_pipe(&pipe, &user, &pass);
merged.merge(&properties);
// Keep the explicit pipe path and plain-security defaults after user overrides.
merged.set_named_pipe(if pipe.starts_with(r"\\.\pipe\") || pipe.starts_with(r"\\?\pipe\") {
pipe
} else {
format!(r"\\.\pipe\{pipe}")
});
crate::sandbox::reassert_named_pipe_security(&mut merged);
properties = merged;
}
}

Ok(Request::Connect {
properties,
log_directive: args.log_directive,
})
}

#[cfg(windows)]
fn run_sandbox_command(args: SandboxArgs) -> anyhow::Result<()> {
match args.command {
SandboxCommand::List => {
let ids = crate::sandbox::list_sandbox_ids().context("list Windows sandboxes")?;
if ids.is_empty() {
println!("(no running sandboxes)");
} else {
for id in ids {
println!("{id}");
}
}
Ok(())
}
SandboxCommand::Config { id } => {
let cfg = crate::sandbox::get_rdp_config(&id).context("get sandbox RDP config")?;
crate::sandbox::print_config_summary(&cfg);
Ok(())
}
SandboxCommand::Stop { id } => {
crate::sandbox::stop_sandbox(&id).context("stop sandbox")?;
println!("stopped {id}");
Ok(())
}
}
}

async fn run_now(endpoint: &Endpoint, args: NowArgs) -> anyhow::Result<Option<u32>> {
let NowArgs { format, command } = args;
match command {
Expand Down
25 changes: 24 additions & 1 deletion crates/ironrdp-agent/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Override with `--endpoint <PATH-OR-PIPE>` on any subcommand.
file line (TYPE is `i` for integer or `s` for string), e.g.
`--prop ironrdp_autologon:i:1`. Check `status` to see whether
credentials are already loaded before supplying any yourself.
- `connect [--rdp-file F] [--prop KEY:TYPE:VALUE]... [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--log-directive D]`
- `connect [--rdp-file F] [--prop KEY:TYPE:VALUE]... [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--sandbox-id ID] [--sandbox-pipe PATH] [--log-directive D]`
Merge an optional .rdp file with CLI overrides into one config and
open a session. Precedence (low to high): .rdp file -> `--prop`
overrides -> named flags (`--server`/`-u`/`-p`/`-d`). When those
Expand All @@ -54,6 +54,12 @@ Override with `--endpoint <PATH-OR-PIPE>` on any subcommand.
(e.g. `ironrdp_connector=trace`) on top of the default `debug`
level; use it to troubleshoot a connection, then read the result
with `query-logs`.
On Windows, `--sandbox-id` resolves NamedPipe RDP settings via
WindowsSandboxServer gRPC (create the VM first with `wsb start`).
Sandbox defaults are the base; explicit file/prop/flags override
them, except NamedPipe TLS/CredSSP stay forced off. Prefer
`--sandbox-id` over `--sandbox-pipe`; the pipe escape hatch needs
`-u`/`-p` (guest password from `sandbox config`).
- `disconnect` Tear down the current session (daemon keeps running).
- `status` Report connection state, destination, last frame size, and whether
credentials are preloaded (`credentials loaded: true|false`). Query
Expand Down Expand Up @@ -133,6 +139,23 @@ event lines. JSON output represents raw bytes as byte arrays and is bounded to 8
Shell execution is intentionally not exposed: there is no `now shell` command, IPC request,
capability, or mapping, even if a peer advertises it.

## Windows Sandbox (Windows only)

Prefer create-then-connect: start the VM with official `wsb start` (prints the sandbox Id), then
attach with the agent. The agent calls WindowsSandboxServer gRPC in-process over the per-user
named pipe (no .NET helper).

- `sandbox list` List running sandbox Ids via WindowsSandboxServer.
- `sandbox config <ID>` Print a redacted RdpClientConfig summary (password shown as set/empty).
- `sandbox stop <ID>` Shut down a running sandbox via gRPC.
- `connect --sandbox-id <ID>` Fetch config + connect over `\\.\pipe\{VMId}` (PROTOCOL_RDP /
ENCRYPTION_LEVEL_NONE). Daemon must already be running.
- `connect --sandbox-pipe PATH -u USER -p PASS`
Low-level NamedPipe connect when you already have the guest password.

Default product transport is NamedPipe. Local (VMConnect :2179 + PCB) and guest TCP are not the
primary path.

## Errors

Failures print a single lowercase message (no trailing punctuation) and exit non-zero. A failed
Expand Down
5 changes: 5 additions & 0 deletions crates/ironrdp-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
pub mod cli;

pub(crate) mod help;

#[cfg(windows)]
pub(crate) mod sandbox;
#[cfg(windows)]
pub(crate) mod sandbox_grpc;
Loading
Loading