Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ab9d1a7
refactor(dgw): CredSSP enclosure, supermarket store, thin synthetic KDC
irvingoujAtDevolution Jul 30, 2026
2a86354
fix(dgw): address Copilot review on CredSSP/provisioning refactor
irvingoujAtDevolution Jul 31, 2026
dc7c482
style(dgw): rustfmt expect attribute
irvingoujAtDevolution Jul 31, 2026
b34472f
fix(dgw): pin injection destination to dst_hst
irvingoujAtDevolution Aug 14, 2026
038ca3d
ci(package): retry Windows tool installs
irvingoujAtDevolution Aug 17, 2026
731ce31
chore: remove unrelated package CI changes
irvingoujAtDevolution Aug 17, 2026
2e10e60
fix(dgw): make injection checkout fail closed
irvingoujAtDevolution Aug 17, 2026
e855268
docs(openapi): document one-shot provisioning TTL
irvingoujAtDevolution Aug 17, 2026
6991021
docs(dgw): clarify Kerberos SPN contract
irvingoujAtDevolution Aug 17, 2026
180aa0c
revert: remove one-shot OpenAPI documentation
irvingoujAtDevolution Aug 17, 2026
8eb632d
fix(dgw): keep injection mappings across reconnects
irvingoujAtDevolution Aug 20, 2026
bdc3382
test(dgw): require exp on provision-credentials fixtures
irvingoujAtDevolution Aug 20, 2026
e826133
fix(dgw): checkout injection before connecting upstream
irvingoujAtDevolution Aug 21, 2026
a2c9dd6
docs(dgw): document credential injection intent
irvingoujAtDevolution Aug 25, 2026
8951d37
docs(dgw): clarify credential injection intent
irvingoujAtDevolution Aug 25, 2026
bda124d
fix(dgw): align credential injection lifecycle
irvingoujAtDevolution Aug 25, 2026
85545af
fix(dgw): release credentials after CredSSP
irvingoujAtDevolution Aug 25, 2026
6ff1270
test(dgw): cover credential injection reconnect
irvingoujAtDevolution Aug 20, 2026
920df36
style(dgw): fix clippy literal suffixes in injection e2e
irvingoujAtDevolution Aug 20, 2026
78a204a
test(dgw): complete Kerberos injection against a mock KDC
irvingoujAtDevolution Aug 20, 2026
6667a0a
feat(dgw): enable Kerberos injection without debug flags
irvingoujAtDevolution Aug 25, 2026
0517eca
test(dgw): cover Kerberos client leg and fail-closed paths
irvingoujAtDevolution Aug 20, 2026
9809185
test(dgw): assert KDC principals and CredSSP identities
irvingoujAtDevolution Aug 21, 2026
958d85b
test(dgw): decode cookies and KdcProxy AS-REQ principal
irvingoujAtDevolution Aug 21, 2026
82086fd
test(dgw): tighten hop asserts without Gateway source changes
irvingoujAtDevolution Aug 21, 2026
171a18c
test(dgw): assert missing krb_kdc never dials the target
irvingoujAtDevolution Aug 21, 2026
2847ec1
test(dgw): drive RDCleanPath injection with ironrdp-agent 0.1.0
irvingoujAtDevolution Aug 21, 2026
0bf9b6d
test(dgw): tighten injection e2e review nits
irvingoujAtDevolution Aug 24, 2026
3241eaf
test(dgw): run injection E2E without debug flags
irvingoujAtDevolution Aug 25, 2026
7a6fd9a
test(dgw): allow forwarding after staging expiry
irvingoujAtDevolution Aug 25, 2026
b6c2173
Merge branch 'master' into refactor/rdp-proxy-credssp-extract
irvingoujAtDevolution Aug 26, 2026
a5dbd91
Merge branch 'refactor/rdp-proxy-credssp-extract' into feat/enable-ke…
irvingoujAtDevolution Aug 26, 2026
f2a9100
Merge branch 'feat/enable-kerberos-injection' into test/cred-injectio…
irvingoujAtDevolution Aug 26, 2026
2bc4908
test(dgw): use direct rustls paths
irvingoujAtDevolution Aug 26, 2026
f2c24bd
test(dgw): move injection helpers into testsuite lib
irvingoujAtDevolution Aug 27, 2026
4fa7faf
Merge remote-tracking branch 'origin/master' into test/cred-injection…
irvingoujAtDevolution Aug 27, 2026
4659ab1
test(dgw): clarify credential injection tests
irvingoujAtDevolution Aug 28, 2026
2298167
test(dgw): keep one intent file for the injection suite
irvingoujAtDevolution Aug 28, 2026
0eeea97
test(dgw): require ironrdp-agent for RDCleanPath coverage
irvingoujAtDevolution Aug 28, 2026
8d600d8
test(dgw): lift ironrdp-agent harness to a top-level helper
irvingoujAtDevolution Aug 28, 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 @@ -228,6 +228,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,22 +18,32 @@ 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-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 @@ -50,14 +60,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 @@ -6,5 +6,8 @@

pub mod cli;
pub mod dgw_config;
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