Skip to content
Open
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
eddecd0
test(dgw): cover credential injection reconnect
irvingoujAtDevolution Aug 20, 2026
6872062
style(dgw): fix clippy literal suffixes in injection e2e
irvingoujAtDevolution Aug 20, 2026
4baae67
test(dgw): complete Kerberos injection against a mock KDC
irvingoujAtDevolution Aug 20, 2026
1aa7af5
test(dgw): cover Kerberos client leg and fail-closed paths
irvingoujAtDevolution Aug 20, 2026
e848355
test(dgw): assert KDC principals and CredSSP identities
irvingoujAtDevolution Aug 21, 2026
b23cce6
test(dgw): decode cookies and KdcProxy AS-REQ principal
irvingoujAtDevolution Aug 21, 2026
b12afcd
test(dgw): tighten hop asserts without Gateway source changes
irvingoujAtDevolution Aug 21, 2026
48ec560
test(dgw): assert missing krb_kdc never dials the target
irvingoujAtDevolution Aug 21, 2026
3f0413a
test(dgw): drive RDCleanPath injection with ironrdp-agent 0.1.0
irvingoujAtDevolution Aug 21, 2026
4ef0c02
test(dgw): tighten injection e2e review nits
irvingoujAtDevolution Aug 24, 2026
63cb211
test(dgw): run injection E2E without debug flags
irvingoujAtDevolution Aug 25, 2026
e85dca7
test(dgw): allow forwarding after staging expiry
irvingoujAtDevolution Aug 25, 2026
71f1f9b
test(dgw): use direct rustls paths
irvingoujAtDevolution Aug 26, 2026
18ca175
test(dgw): move injection helpers into testsuite lib
irvingoujAtDevolution Aug 27, 2026
3c66723
test(dgw): clarify credential injection tests
irvingoujAtDevolution Aug 28, 2026
b190a2b
test(dgw): keep one intent file for the injection suite
irvingoujAtDevolution Aug 28, 2026
c5061d7
test(dgw): require ironrdp-agent for RDCleanPath coverage
irvingoujAtDevolution Aug 28, 2026
716aeb0
test(dgw): lift ironrdp-agent harness to a top-level helper
irvingoujAtDevolution Aug 28, 2026
87daa2b
chore(testsuite): refresh Cargo.lock for injection test deps
irvingoujAtDevolution Sep 24, 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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,22 @@ jobs:
sudo apt-get update
sudo apt-get -o Acquire::Retries=3 install libsystemd-dev

## Required by the RDCleanPath credential-injection tests in the testsuite.
## Keep the version in sync with IRONRDP_AGENT_VERSION in testsuite/src/ironrdp_agent.rs.
- name: Cache ironrdp-agent
id: cache-ironrdp-agent
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/ironrdp-agent
~/.cargo/bin/ironrdp-agent.exe
key: ironrdp-agent-0.1.0-${{ runner.os }}

- name: Install ironrdp-agent
if: ${{ steps.cache-ironrdp-agent.outputs.cache-hit != 'true' }}
shell: pwsh
run: cargo install ironrdp-agent --version 0.1.0 --locked

- name: Tests
run: ./ci/tlk.ps1 test -Platform ${{ matrix.os }} -Architecture ${{ matrix.arch }} -CargoProfile 'dev'
shell: pwsh
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

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

16 changes: 12 additions & 4 deletions testsuite/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,33 @@ harness = true
[dependencies]
anyhow = "1.0"
assert_cmd = "2.2"
base64 = "0.23"
dynosaur = "0.3"
escargot = "0.5"
expect-test = "1.5"
fastrand = "2"
ironrdp-connector = "0.10"
ironrdp-core = { version = "0.2", features = ["std"] }
ironrdp-pdu = { version = "0.9", features = ["std"] }
ironrdp-tokio = "0.10"
kdc = "0.1"
picky-asn1-der = "0.5"
picky-krb = "0.12"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process", "io-util"] }
tokio-rustls = { version = "0.26", features = ["ring"] }
tokio-util = "0.7"
typed-builder = "0.21"
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] }
x509-cert = { version = "0.3", default-features = false, features = ["std"] }

[dev-dependencies]
agent-tunnel = { path = "../crates/agent-tunnel", features = ["test-utils"] }
agent-tunnel-libsql = { path = "../crates/agent-tunnel-libsql" }
agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde"] }
base64 = "0.23"
camino = "1"
devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" }
devolutions-gateway = { path = "../devolutions-gateway" }
Expand All @@ -52,14 +62,12 @@ quinn = "0.11"
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
reqwest = { version = "0.12", default-features = false, features = ["json"] }
rstest = "0.25"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
rustls-pki-types = "1"
serde_json = "1"
sysevent.path = "../crates/sysevent"
tempfile = "3"
test-utils.path = "../crates/test-utils"
tokio-rustls = { version = "0.26", features = ["ring"] }
uuid = { version = "1", features = ["v4"] }

[target.'cfg(unix)'.dev-dependencies]
Expand Down
157 changes: 157 additions & 0 deletions testsuite/src/ironrdp_agent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Drives the public `ironrdp-agent` CLI as a real RDP client for process-level Gateway tests.
//! The binary is required (`cargo install ironrdp-agent --version 0.1.0`); set
//! `IRONRDP_AGENT_SKIP=1` to skip agent-based tests instead.

use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

use anyhow::Context as _;

pub const IRONRDP_AGENT_VERSION: &str = "0.1.0";

fn ironrdp_agent_bin() -> Option<PathBuf> {
if let Ok(path) = std::env::var("IRONRDP_AGENT") {
return Some(PathBuf::from(path));
}
let name = if cfg!(windows) {
"ironrdp-agent.exe"
} else {
"ironrdp-agent"
};
if let Ok(home) = std::env::var("CARGO_HOME") {
let path = PathBuf::from(home).join("bin").join(name);
if path.is_file() {
return Some(path);
}
}
let cargo_home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.map(|home| home.join(".cargo").join("bin").join(name));
if let Some(path) = cargo_home
&& path.is_file()
{
return Some(path);
}
if let Ok(path) = std::env::var("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}

pub fn require_ironrdp_agent() -> anyhow::Result<Option<PathBuf>> {
if std::env::var_os("IRONRDP_AGENT_SKIP").is_some_and(|value| !value.is_empty()) {
eprintln!("skipping ironrdp-agent test: IRONRDP_AGENT_SKIP is set");
return Ok(None);
}
let Some(bin) = ironrdp_agent_bin() else {
anyhow::bail!(
"ironrdp-agent {IRONRDP_AGENT_VERSION} is required: \
`cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}`, \
or set IRONRDP_AGENT_SKIP=1 to skip"
);
};
let output = std::process::Command::new(&bin)
.arg("--version")
.output()
.with_context(|| format!("run {} --version", bin.display()))?;
let version = String::from_utf8_lossy(&output.stdout);
anyhow::ensure!(
version.contains(IRONRDP_AGENT_VERSION),
"expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}",
bin.display()
);
Ok(Some(bin))
}

pub fn ironrdp_agent_endpoint() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(1);
let name = format!(
"ironrdp-e2e-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
if cfg!(windows) {
format!(r"\\.\pipe\{name}")
} else {
std::env::temp_dir().join(format!("{name}.sock")).display().to_string()
}
}

pub async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result<tokio::process::Child> {
let child = tokio::process::Command::new(bin)
.args(["--endpoint", endpoint, "daemon-start"])
.kill_on_drop(true)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("start ironrdp-agent daemon")?;
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let status = tokio::process::Command::new(bin)
.args(["--endpoint", endpoint, "status"])
.output()
.await
.context("ironrdp-agent status")?;
if status.status.success() {
return Ok(child);
}
if Instant::now() >= deadline {
anyhow::bail!(
"ironrdp-agent daemon not ready at {endpoint}: {}",
String::from_utf8_lossy(&status.stderr)
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}

pub async fn connect_rdcleanpath(
bin: &Path,
endpoint: &str,
server: &str,
username: &str,
password: &str,
association_token: &str,
http_port: u16,
) -> anyhow::Result<tokio::process::Child> {
let url = format!("ws://127.0.0.1:{http_port}/jet/rdp");
tokio::process::Command::new(bin)
.args([
"--endpoint",
endpoint,
"connect",
"--server",
server,
"--username",
username,
"--password",
password,
"--prop",
&format!("ironrdp_rdcleanpathurl:s:{url}"),
"--prop",
&format!("ironrdp_rdcleanpathtoken:s:{association_token}"),
])
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("start ironrdp-agent connect")
}

pub async fn agent_query_logs(bin: &Path, endpoint: &str) -> String {
tokio::process::Command::new(bin)
.args(["--endpoint", endpoint, "query-logs"])
.output()
.await
.ok()
.map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
.unwrap_or_default()
}
3 changes: 3 additions & 0 deletions testsuite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
pub mod cli;
pub mod dgw_config;
pub mod http;
pub mod ironrdp_agent;
pub mod mcp_client;
pub mod mcp_server;
pub mod rdp_injection;
pub mod tls_fixtures;
28 changes: 28 additions & 0 deletions testsuite/src/rdp_injection/INTENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Intent

## Senarios
1. NTLM
a. Preflight send a NTLM credential injection request to the Gateway
b. RDP client will send association token with matching JTI in the credential injection request to Gateway.
c. Gateway will correctly recognize this connetion is intended for credential injection
d. RDP client will proceed with TLS + CredSSP
e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server.
f. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials


2. Kerberos
a. Preflight send a Kerberos credential injection request to the Gateway including the KDC information.
b. RDP client will send association token with matching JTI in the credential injection request to Gateway.
c. Gateway will correctly recognize this connetion is intended for credential injection
d. RDP client will proceed with TLS + CredSSP, here it will also call KDC Proxy (which is also Gateway)
e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server.
f. Gateway will forward the Kerberos request to the KDC with state driven by the target credentials, and we will assert on that the KDC receives the correct request.
g. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials

3. Reconnect stability, make sure we could reconnect with the same association token.

4. Failure behavior
a. Missing or expired provisioning fails at 1.c or 2.c and uses ordinary forwarding.
b. An incorrect target password or unavailable KDC fails at 2.e and must not fall back to ordinary forwarding.
c. Missing KDC information fails before 2.e and must not connect to the RDP server.

Loading
Loading