Skip to content

Commit 8bf3010

Browse files
feat(agent): add verify-tunnel subcommand with structured error catalog
Adds `agent.exe verify-tunnel --timeout <secs>` which performs one QUIC handshake plus one RouteAdvertise + Heartbeat/HeartbeatAck round-trip and exits 0 on success or 1 on any classified failure. The last line of stderr is a single-line JSON triple `{kind, detail, next_step}` consumed by the installer custom action to surface actionable error dialogs. Implements the 9+1-kind error catalog from the design doc (section 6): enrollment_host_not_advertised, dns_resolution_failed, udp_unreachable, tls_san_mismatch, tls_spki_pin_mismatch, quic_handshake_timeout, route_advertise_timeout, enrollment_token_expired, enrollment_token_signature_invalid, and the unexpected_error catch-all which always carries a correlation_id and log path. On Windows the triple is also written to the Event Log under the DevolutionsAgent source with kind/detail/next_step as named properties so monitoring tools can parse failures without scraping text.
1 parent cf4abdf commit 8bf3010

4 files changed

Lines changed: 863 additions & 0 deletions

File tree

devolutions-agent/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ features = [
8585
"Win32_Foundation",
8686
"Win32_Storage_FileSystem",
8787
"Win32_Security",
88+
"Win32_System_EventLog",
8889
"Win32_System_SystemInformation",
8990
"Win32_System_Threading",
9091
"Win32_Security_Cryptography",

devolutions-agent/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod log;
1212
pub mod remote_desktop;
1313
pub mod tunnel;
1414
mod tunnel_helpers;
15+
pub mod verify_tunnel;
1516

1617
#[cfg(windows)]
1718
pub mod session_manager;

devolutions-agent/src/main.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,31 @@ fn parse_advertise_subnets(value: &str) -> Vec<String> {
142142
.collect()
143143
}
144144

145+
/// Default verify-tunnel timeout (matches what the installer CA hardcodes).
146+
const VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS: u64 = 10;
147+
148+
/// Parse `verify-tunnel` CLI arguments. Currently supports a single `--timeout
149+
/// <secs>` flag and falls back to [`VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS`] when
150+
/// absent.
151+
fn parse_verify_tunnel_args(args: &[String]) -> Result<std::time::Duration> {
152+
let mut timeout_secs = VERIFY_TUNNEL_DEFAULT_TIMEOUT_SECS;
153+
let mut index = 0;
154+
while index < args.len() {
155+
match args[index].as_str() {
156+
"--timeout" => {
157+
let value = parse_required_value(args, &mut index, "--timeout")?;
158+
timeout_secs = value.parse::<u64>().context("--timeout must be a positive integer (seconds)")?;
159+
if timeout_secs == 0 {
160+
bail!("--timeout must be > 0");
161+
}
162+
}
163+
unexpected => bail!("unknown argument for verify-tunnel: {unexpected}"),
164+
}
165+
index += 1;
166+
}
167+
Ok(std::time::Duration::from_secs(timeout_secs))
168+
}
169+
145170
fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
146171
let mut gateway_url = None;
147172
let mut enrollment_token = None;
@@ -258,6 +283,50 @@ fn main() {
258283
}
259284
});
260285
}
286+
"verify-tunnel" => {
287+
let args: Vec<String> = env::args().skip(2).collect();
288+
let timeout = match parse_verify_tunnel_args(&args) {
289+
Ok(timeout) => timeout,
290+
Err(error) => {
291+
eprintln!("[ERROR] Invalid verify-tunnel arguments: {error:#}");
292+
// Emit a structured unexpected_error triple so the installer CA still
293+
// has something parseable on stderr.
294+
let triple = devolutions_agent::verify_tunnel::ErrorTriple::new(
295+
devolutions_agent::verify_tunnel::ErrorKind::UnexpectedError,
296+
format!("verify-tunnel argument parse error: {error:#}"),
297+
);
298+
triple.emit_to_stderr();
299+
std::process::exit(1);
300+
}
301+
};
302+
303+
let conf_handle = match ConfHandle::init() {
304+
Ok(handle) => handle,
305+
Err(error) => {
306+
let triple = devolutions_agent::verify_tunnel::ErrorTriple::new(
307+
devolutions_agent::verify_tunnel::ErrorKind::UnexpectedError,
308+
format!("failed to load agent configuration: {error:#}"),
309+
);
310+
triple.emit_to_stderr();
311+
std::process::exit(1);
312+
}
313+
};
314+
315+
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
316+
let result = rt.block_on(devolutions_agent::verify_tunnel::verify_tunnel(&conf_handle, timeout));
317+
318+
match result {
319+
Ok(()) => {
320+
// Success path: nothing on stderr — installer CA only consumes stderr
321+
// and absence of a JSON triple confirms the verification succeeded.
322+
println!("verify-tunnel: tunnel is reachable and route-advertise round-trip ok");
323+
}
324+
Err(triple) => {
325+
triple.emit_to_stderr();
326+
std::process::exit(1);
327+
}
328+
}
329+
}
261330
"up" => {
262331
let args: Vec<String> = env::args().skip(2).collect();
263332
let command = match parse_up_command_args(&args) {
@@ -356,6 +425,30 @@ mod tests {
356425
)
357426
}
358427

428+
#[test]
429+
fn parse_verify_tunnel_defaults_to_10s() {
430+
let timeout = parse_verify_tunnel_args(&[]).expect("parse empty");
431+
assert_eq!(timeout, std::time::Duration::from_secs(10));
432+
}
433+
434+
#[test]
435+
fn parse_verify_tunnel_explicit_timeout() {
436+
let timeout = parse_verify_tunnel_args(&["--timeout".to_owned(), "30".to_owned()]).expect("parse");
437+
assert_eq!(timeout, std::time::Duration::from_secs(30));
438+
}
439+
440+
#[test]
441+
fn parse_verify_tunnel_rejects_zero_timeout() {
442+
let err = parse_verify_tunnel_args(&["--timeout".to_owned(), "0".to_owned()]).expect_err("expect rejection");
443+
assert!(format!("{err:#}").contains("> 0"), "{err:#}");
444+
}
445+
446+
#[test]
447+
fn parse_verify_tunnel_rejects_unknown_flag() {
448+
let err = parse_verify_tunnel_args(&["--bogus".to_owned()]).expect_err("expect rejection");
449+
assert!(format!("{err:#}").contains("--bogus"), "{err:#}");
450+
}
451+
359452
#[test]
360453
fn parse_up_command_args_accepts_enrollment_string() {
361454
let jwt = make_jwt(serde_json::json!({

0 commit comments

Comments
 (0)