From 99d54899bdf6e114c94355e3e9ad28aedaf56e5d Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:52:47 -0400 Subject: [PATCH] Add trace command for reconnect debugging --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 +- src/bin/dosh-client.rs | 284 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 289 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3729e9..bc184e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "1.0.0-rc29" +version = "1.0.0-rc30" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index c20c0cc..7d08590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "1.0.0-rc29" +version = "1.0.0-rc30" edition = "2024" license = "MIT" diff --git a/README.md b/README.md index 80e13de..07e6394 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,18 @@ File copy must be enabled by the server config. For terminal/reconnect bugs, run a client with: ```sh -DOSH_TRACE=/tmp/dosh-client.log DOSH_TRACE_BYTES=1 dosh HOST +dosh trace HOST +``` + +The client log path is printed before the session starts. To choose it: + +```sh +dosh trace --client-log /tmp/dosh-client.log HOST ``` Set `DOSH_TRACE=/tmp/dosh-server.log` on `dosh-server` for matching server -events. `DOSH_TRACE_BYTES=1` records byte prefixes, so use it only for short -reproductions. +events. Trace byte prefixes are enabled for `dosh trace`, so use it only for +short reproductions. ## VS Code diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 3df627e..725968a 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -56,7 +56,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] use tokio::net::UnixStream; @@ -265,6 +265,9 @@ async fn main() -> Result<()> { if args.server.as_deref() == Some("update") { return run_update(&config, parse_update_options(&args.command)?); } + if args.server.as_deref() == Some("trace") { + return run_trace_command(&args); + } if args.server.as_deref() == Some("setup") { return run_setup_command(&config, &args).await; } @@ -776,6 +779,224 @@ async fn run_selftest_command(config: &dosh::config::ClientConfig, args: &Args) Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct TraceOptions { + host: String, + command: Vec, + client_log: PathBuf, + bytes: bool, +} + +fn parse_trace_options(command: &[String]) -> Result { + let mut client_log: Option = None; + let mut bytes = true; + let mut index = 0usize; + while index < command.len() { + let arg = &command[index]; + match arg.as_str() { + "--bytes" => { + bytes = true; + index += 1; + } + "--no-bytes" => { + bytes = false; + index += 1; + } + "--client-log" => { + let path = command.get(index + 1).ok_or_else(|| { + anyhow!( + "usage: dosh trace [--client-log PATH] [--no-bytes] [command...]" + ) + })?; + client_log = Some(expand_tilde(path)); + index += 2; + } + "--" => { + index += 1; + break; + } + value if value.starts_with("--client-log=") => { + let path = value + .strip_prefix("--client-log=") + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("--client-log requires a path"))?; + client_log = Some(expand_tilde(path)); + index += 1; + } + value if value.starts_with('-') => { + return Err(anyhow!( + "usage: dosh trace [--client-log PATH] [--no-bytes] [command...] (unknown option {value})" + )); + } + _ => break, + } + } + let host = command.get(index).cloned().ok_or_else(|| { + anyhow!("usage: dosh trace [--client-log PATH] [--no-bytes] [command...]") + })?; + let mut command_start = index + 1; + if command + .get(command_start) + .is_some_and(|value| value == "--") + { + command_start += 1; + } + let command = command[command_start..].to_vec(); + let client_log = client_log.unwrap_or_else(|| default_client_trace_path(&host)); + Ok(TraceOptions { + host, + command, + client_log, + bytes, + }) +} + +fn default_client_trace_path(host: &str) -> PathBuf { + let root = dirs::cache_dir() + .unwrap_or_else(|| expand_tilde("~/.cache")) + .join("dosh") + .join("trace"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + root.join(format!( + "client-{}-{}-{}.log", + sanitize_trace_name(host), + now, + std::process::id() + )) +} + +fn sanitize_trace_name(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') { + out.push(byte as char); + } else { + out.push('_'); + } + } + if out.is_empty() { + "host".to_string() + } else { + out + } +} + +fn run_trace_command(args: &Args) -> Result<()> { + let options = parse_trace_options(&args.command)?; + if let Some(parent) = options.client_log.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create trace log directory {}", parent.to_string_lossy()))?; + } + let exe = std::env::current_exe().context("locate current dosh executable")?; + println!("Dosh trace client log: {}", options.client_log.display()); + println!("Dosh trace server log: /tmp/dosh-server.log"); + if options.bytes { + println!("Dosh trace byte prefixes: enabled"); + } else { + println!("Dosh trace byte prefixes: disabled"); + } + let mut child = Command::new(exe); + push_trace_passthrough_args(&mut child, args); + child.arg(&options.host); + if !options.command.is_empty() { + child.arg("--"); + child.args(&options.command); + } + child.env("DOSH_TRACE", &options.client_log); + child.env("DOSH_TRACE_BYTES", if options.bytes { "1" } else { "0" }); + let status = child + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .context("run traced dosh client")?; + if !status.success() { + return Err(anyhow!("traced dosh exited with status {status}")); + } + Ok(()) +} + +fn push_trace_passthrough_args(command: &mut Command, args: &Args) { + if let Some(session) = &args.session { + command.arg("--session").arg(session); + } + if args.new { + command.arg("--new"); + } + if args.view_only { + command.arg("--view-only"); + } + if args.local_auth { + command.arg("--local-auth"); + } + if let Some(auth) = &args.auth { + command.arg("--auth").arg(auth); + } + if args.no_cache { + command.arg("--no-cache"); + } + for forward in &args.local_forward { + command.arg("-L").arg(forward); + } + for forward in &args.remote_forward { + command.arg("-R").arg(forward); + } + for forward in &args.dynamic_forward { + command.arg("-D").arg(forward); + } + if args.forward_agent { + command.arg("-A"); + } + if args.forward_only { + command.arg("-N"); + } + if args.background { + command.arg("-f"); + } + if args.predict { + command.arg("--predict"); + } + if let Some(mode) = &args.predict_mode { + command.arg("--predict-mode").arg(mode); + } + if args.predict_always { + command.arg("--predict-always"); + } + if args.predict_never { + command.arg("--predict-never"); + } + if let Some(escape_key) = &args.escape_key { + command.arg("--escape-key").arg(escape_key); + } + if let Some(port) = args.ssh_port { + command.arg("--ssh-port").arg(port.to_string()); + } + if let Some(auth_command) = &args.ssh_auth_command { + command.arg("--ssh-auth-command").arg(auth_command); + } + if let Some(path) = &args.ssh_key { + command.arg("--ssh-key").arg(path); + } + if let Some(path) = &args.ssh_known_hosts { + command.arg("--ssh-known-hosts").arg(path); + } + if let Some(path) = &args.ssh_control_path { + command.arg("--ssh-control-path").arg(path); + } + if let Some(port) = args.dosh_port { + command.arg("--dosh-port").arg(port.to_string()); + } + if let Some(host) = &args.dosh_host { + command.arg("--dosh-host").arg(host); + } + for _ in 0..args.verbose { + command.arg("-v"); + } +} + async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { let requested = args .command @@ -8280,11 +8501,12 @@ mod tests { input_matches_escape, is_local_status_target, is_resume_response_for_client, latest_release_download_url, load_first_native_identity_with_prompt, native_proxy_udp_warning, parse_dynamic_forward, parse_escape_key, parse_local_forward, - parse_remote_forward, parse_ssh_config, parse_update_options, post_submit_hold_duration, - queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr, - release_tag_download_url, release_tag_from_effective_url, release_version_from_tag, - render_status_clear, render_status_overlay, requested_env, resolved_startup_command, - retire_stream_state, retransmit_stream_opens, rewrite_forward_command, + parse_remote_forward, parse_ssh_config, parse_trace_options, parse_update_options, + post_submit_hold_duration, queue_pending_user_input, raw_contains_host_table, + recv_response_until, refresh_live_addr, release_tag_download_url, + release_tag_from_effective_url, release_version_from_tag, render_status_clear, + render_status_overlay, requested_env, resolved_startup_command, retire_stream_state, + retransmit_stream_opens, rewrite_forward_command, sanitize_trace_name, selected_predict_mode, selected_udp_host, server_version_mismatch, should_flush_terminal_input_after_contact, should_hold_during_startup_gate, should_hold_post_submit_input, should_repaint_idle_alternate_screen, @@ -9554,6 +9776,56 @@ mod tests { assert!(parse_update_options(&["--client".to_string(), "--server".to_string()]).is_err()); } + #[test] + fn trace_options_parse_host_command_and_defaults() { + let parsed = parse_trace_options(&["palav".to_string(), "tm".to_string()]).unwrap(); + + assert_eq!(parsed.host, "palav"); + assert_eq!(parsed.command, vec!["tm"]); + assert!(parsed.bytes); + assert!( + parsed + .client_log + .to_string_lossy() + .contains("client-palav-") + ); + } + + #[test] + fn trace_options_accept_client_log_and_no_bytes() { + let parsed = parse_trace_options(&[ + "--no-bytes".to_string(), + "--client-log".to_string(), + "/tmp/dosh-client.log".to_string(), + "palav".to_string(), + "--".to_string(), + "echo".to_string(), + "ok".to_string(), + ]) + .unwrap(); + + assert_eq!(parsed.host, "palav"); + assert_eq!(parsed.command, vec!["echo", "ok"]); + assert_eq!( + parsed.client_log, + std::path::PathBuf::from("/tmp/dosh-client.log") + ); + assert!(!parsed.bytes); + } + + #[test] + fn trace_options_reject_unknown_options_before_host() { + assert!(parse_trace_options(&["--wat".to_string(), "palav".to_string()]).is_err()); + assert!(parse_trace_options(&[]).is_err()); + } + + #[test] + fn trace_log_name_is_shell_and_path_safe() { + assert_eq!(sanitize_trace_name("palav"), "palav"); + assert_eq!(sanitize_trace_name("user@host:50000"), "user_host_50000"); + assert_eq!(sanitize_trace_name(""), "host"); + } + #[test] fn update_installer_uses_platform_native_script() { assert_eq!(