Skip to content

Commit bc79ff7

Browse files
committed
Fix ssh option processing
I got bit badly by the problem that `ssh` always runs a remote shell and basically just does the completely wrong thing if you pass it multiple things in argv. While we're here fix things so libvirt and ephemeral share more code. Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 34d6c32 commit bc79ff7

3 files changed

Lines changed: 174 additions & 59 deletions

File tree

crates/kit/src/libvirt/ssh.rs

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -245,36 +245,38 @@ impl LibvirtSshOpts {
245245
// Build SSH command
246246
let mut ssh_cmd = Command::new("ssh");
247247

248-
// Basic SSH options
248+
// Add SSH key and port
249249
ssh_cmd
250250
.arg("-i")
251251
.arg(temp_key.path())
252252
.arg("-p")
253-
.arg(ssh_config.ssh_port.to_string())
254-
.args(["-o", "IdentitiesOnly=yes"])
255-
.arg("-o")
256-
.arg("PasswordAuthentication=no")
257-
.arg("-o")
258-
.arg("ConnectTimeout=30")
259-
.arg("-o")
260-
.arg("ServerAliveInterval=60");
261-
262-
// Host key checking
263-
if !self.strict_host_keys {
264-
ssh_cmd
265-
.arg("-o")
266-
.arg("StrictHostKeyChecking=no")
267-
.arg("-o")
268-
.arg("UserKnownHostsFile=/dev/null");
269-
}
253+
.arg(ssh_config.ssh_port.to_string());
254+
255+
// Apply common SSH options
256+
let common_opts = crate::ssh::CommonSshOptions {
257+
strict_host_keys: self.strict_host_keys,
258+
connect_timeout: self.timeout,
259+
server_alive_interval: 60,
260+
log_level: "ERROR".to_string(),
261+
extra_options: vec![],
262+
};
263+
common_opts.apply_to_command(&mut ssh_cmd);
270264

271265
// Target host
272266
ssh_cmd.arg(format!("{}@127.0.0.1", self.user));
273267

274-
// Add command if specified
268+
// Add command if specified - use the same argument escaping logic as container SSH
275269
if !self.command.is_empty() {
276270
ssh_cmd.arg("--");
277-
ssh_cmd.args(&self.command);
271+
if self.command.len() > 1 {
272+
// Multiple arguments need proper shell escaping
273+
let combined_command = crate::ssh::shell_escape_command(&self.command);
274+
debug!("Combined escaped command: {}", combined_command);
275+
ssh_cmd.arg(combined_command);
276+
} else {
277+
// Single argument can be passed directly
278+
ssh_cmd.args(&self.command);
279+
}
278280
}
279281

280282
debug!("Executing SSH command: {:?}", ssh_cmd);

crates/kit/src/run_ephemeral_ssh.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ pub fn run_ephemeral_ssh(opts: RunEphemeralSshOpts) -> Result<()> {
166166

167167
// Execute SSH connection directly (no thread needed for this)
168168
// This allows SSH output to be properly forwarded to stdout/stderr
169-
debug!("Connecting to SSH...");
169+
debug!("Connecting to SSH with args: {:?}", opts.ssh_args);
170170
let status = ssh::connect_via_container_with_status(&container_name, opts.ssh_args)?;
171171
debug!("SSH connection completed");
172172

crates/kit/src/ssh.rs

Lines changed: 151 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ use tracing::debug;
99

1010
use crate::CONTAINER_STATEDIR;
1111

12+
/// Combine multiple command arguments into a properly escaped shell command string
13+
///
14+
/// This is necessary because SSH protocol sends commands as strings, not argument arrays.
15+
/// When bcvk receives multiple arguments like ["/bin/sh", "-c", "echo hello; sleep 5"],
16+
/// they must be combined into a single string that will be correctly interpreted by the
17+
/// remote shell.
18+
///
19+
/// Uses the `shlex` crate for robust POSIX shell escaping.
20+
pub fn shell_escape_command(args: &[String]) -> String {
21+
shlex::try_join(args.iter().map(|s| s.as_str()))
22+
.expect("Command arguments should not contain null bytes")
23+
}
24+
1225
/// Represents an SSH keypair with file paths and public key content
1326
#[derive(Debug, Clone)]
1427
pub struct SshKeyPair {
@@ -176,25 +189,76 @@ pub fn connect_via_container(container_name: &str, args: Vec<String>) -> Result<
176189
/// SSH connection configuration options
177190
#[derive(Debug, Clone)]
178191
pub struct SshConnectionOptions {
179-
/// Connection timeout in seconds (default: 30)
180-
pub connect_timeout: u32,
192+
/// Common SSH options shared across implementations
193+
pub common: CommonSshOptions,
181194
/// Enable/disable TTY allocation (default: true)
182195
pub allocate_tty: bool,
183-
/// SSH log level (default: ERROR)
196+
/// Suppress output to stdout/stderr (default: false)
197+
pub suppress_output: bool,
198+
}
199+
200+
/// Common SSH options that can be shared between different SSH implementations
201+
#[derive(Debug, Clone)]
202+
pub struct CommonSshOptions {
203+
/// Use strict host key checking
204+
pub strict_host_keys: bool,
205+
/// SSH connection timeout in seconds
206+
pub connect_timeout: u32,
207+
/// Server alive interval in seconds
208+
pub server_alive_interval: u32,
209+
/// SSH log level
184210
pub log_level: String,
185211
/// Additional SSH options as key-value pairs
186212
pub extra_options: Vec<(String, String)>,
187-
/// Suppress output to stdout/stderr (default: false)
188-
pub suppress_output: bool,
189213
}
190214

191-
impl Default for SshConnectionOptions {
215+
impl Default for CommonSshOptions {
192216
fn default() -> Self {
193217
Self {
218+
strict_host_keys: false,
194219
connect_timeout: 30,
195-
allocate_tty: true,
220+
server_alive_interval: 60,
196221
log_level: "ERROR".to_string(),
197222
extra_options: vec![],
223+
}
224+
}
225+
}
226+
227+
impl CommonSshOptions {
228+
/// Apply these options to an SSH command
229+
pub fn apply_to_command(&self, cmd: &mut std::process::Command) {
230+
// Basic security options
231+
cmd.args(["-o", "IdentitiesOnly=yes"]);
232+
cmd.args(["-o", "PasswordAuthentication=no"]);
233+
cmd.args(["-o", "KbdInteractiveAuthentication=no"]);
234+
cmd.args(["-o", "GSSAPIAuthentication=no"]);
235+
236+
// Connection options
237+
cmd.args(["-o", &format!("ConnectTimeout={}", self.connect_timeout)]);
238+
cmd.args([
239+
"-o",
240+
&format!("ServerAliveInterval={}", self.server_alive_interval),
241+
]);
242+
cmd.args(["-o", &format!("LogLevel={}", self.log_level)]);
243+
244+
// Host key checking
245+
if !self.strict_host_keys {
246+
cmd.args(["-o", "StrictHostKeyChecking=no"]);
247+
cmd.args(["-o", "UserKnownHostsFile=/dev/null"]);
248+
}
249+
250+
// Add extra SSH options
251+
for (key, value) in &self.extra_options {
252+
cmd.args(["-o", &format!("{}={}", key, value)]);
253+
}
254+
}
255+
}
256+
257+
impl Default for SshConnectionOptions {
258+
fn default() -> Self {
259+
Self {
260+
common: CommonSshOptions::default(),
261+
allocate_tty: true,
198262
suppress_output: false,
199263
}
200264
}
@@ -204,10 +268,14 @@ impl SshConnectionOptions {
204268
/// Create options suitable for quick connectivity tests (short timeout, no TTY)
205269
pub fn for_connectivity_test() -> Self {
206270
Self {
207-
connect_timeout: 2,
271+
common: CommonSshOptions {
272+
strict_host_keys: false,
273+
connect_timeout: 2,
274+
server_alive_interval: 60,
275+
log_level: "ERROR".to_string(),
276+
extra_options: vec![],
277+
},
208278
allocate_tty: false,
209-
log_level: "ERROR".to_string(),
210-
extra_options: vec![],
211279
suppress_output: true,
212280
}
213281
}
@@ -251,25 +319,18 @@ pub fn connect_via_container_with_options(
251319
cmd.args(["exec", container_name, "ssh"]);
252320
}
253321

254-
// SSH key and security options
322+
// SSH key
255323
let keypath = Utf8Path::new("/run/tmproot")
256324
.join(CONTAINER_STATEDIR.trim_start_matches('/'))
257325
.join("ssh");
258326
cmd.args(["-i", keypath.as_str()]);
259-
cmd.args(["-o", "IdentitiesOnly=yes"]);
260-
cmd.args(["-o", "PasswordAuthentication=no"]);
261-
cmd.args(["-o", "KbdInteractiveAuthentication=no"]);
262-
cmd.args(["-o", "GSSAPIAuthentication=no"]);
263-
cmd.args(["-o", "StrictHostKeyChecking=no"]);
264-
cmd.args(["-o", "UserKnownHostsFile=/dev/null"]);
265-
266-
// Configurable options
267-
cmd.args(["-o", &format!("ConnectTimeout={}", options.connect_timeout)]);
268-
cmd.args(["-o", &format!("LogLevel={}", options.log_level)]);
269-
270-
// Add extra SSH options
271-
for (key, value) in &options.extra_options {
272-
cmd.args(["-o", &format!("{}={}", key, value)]);
327+
328+
// Apply common SSH options
329+
options.common.apply_to_command(&mut cmd);
330+
331+
// Container SSH always uses batch mode for non-interactive commands
332+
if !options.allocate_tty {
333+
cmd.args(["-o", "BatchMode=yes"]);
273334
}
274335

275336
// Connect to VM via QEMU port forwarding on localhost
@@ -278,11 +339,31 @@ pub fn connect_via_container_with_options(
278339

279340
// Add any additional arguments
280341
if !args.is_empty() {
342+
debug!("Adding SSH arguments: {:?}", args);
281343
cmd.arg("--");
282-
cmd.args(&args);
344+
345+
// If we have multiple arguments, we need to properly combine them into a single
346+
// command string that will survive shell parsing on the remote side.
347+
// This is because SSH protocol sends commands as strings, not argument arrays.
348+
if args.len() > 1 {
349+
// Combine arguments with proper shell escaping
350+
let combined_command = shell_escape_command(&args);
351+
debug!("Combined escaped command: {}", combined_command);
352+
cmd.arg(combined_command);
353+
} else {
354+
// Single argument can be passed directly
355+
cmd.args(&args);
356+
}
283357
}
284358

285359
debug!("Executing: podman {:?}", cmd.get_args().collect::<Vec<_>>());
360+
debug!(
361+
"Full command line: podman {}",
362+
cmd.get_args()
363+
.map(|s| s.to_string_lossy().to_string())
364+
.collect::<Vec<_>>()
365+
.join(" ")
366+
);
286367

287368
// Suppress output if requested (useful for connectivity testing)
288369
if options.suppress_output {
@@ -336,36 +417,68 @@ mod tests {
336417
fn test_ssh_connection_options() {
337418
// Test default options
338419
let default_opts = SshConnectionOptions::default();
339-
assert_eq!(default_opts.connect_timeout, 30);
420+
assert_eq!(default_opts.common.connect_timeout, 30);
340421
assert!(default_opts.allocate_tty);
341-
assert_eq!(default_opts.log_level, "ERROR");
342-
assert!(default_opts.extra_options.is_empty());
422+
assert_eq!(default_opts.common.log_level, "ERROR");
423+
assert!(default_opts.common.extra_options.is_empty());
343424
assert!(!default_opts.suppress_output);
344425

345426
// Test connectivity test options
346427
let test_opts = SshConnectionOptions::for_connectivity_test();
347-
assert_eq!(test_opts.connect_timeout, 2);
428+
assert_eq!(test_opts.common.connect_timeout, 2);
348429
assert!(!test_opts.allocate_tty);
349-
assert_eq!(test_opts.log_level, "ERROR");
350-
assert!(test_opts.extra_options.is_empty());
430+
assert_eq!(test_opts.common.log_level, "ERROR");
431+
assert!(test_opts.common.extra_options.is_empty());
351432
assert!(test_opts.suppress_output);
352433

353434
// Test custom options
354435
let mut custom_opts = SshConnectionOptions::default();
355-
custom_opts.connect_timeout = 10;
436+
custom_opts.common.connect_timeout = 10;
356437
custom_opts.allocate_tty = false;
357-
custom_opts.log_level = "DEBUG".to_string();
438+
custom_opts.common.log_level = "DEBUG".to_string();
358439
custom_opts
440+
.common
359441
.extra_options
360442
.push(("ServerAliveInterval".to_string(), "30".to_string()));
361443

362-
assert_eq!(custom_opts.connect_timeout, 10);
444+
assert_eq!(custom_opts.common.connect_timeout, 10);
363445
assert!(!custom_opts.allocate_tty);
364-
assert_eq!(custom_opts.log_level, "DEBUG");
365-
assert_eq!(custom_opts.extra_options.len(), 1);
446+
assert_eq!(custom_opts.common.log_level, "DEBUG");
447+
assert_eq!(custom_opts.common.extra_options.len(), 1);
366448
assert_eq!(
367-
custom_opts.extra_options[0],
449+
custom_opts.common.extra_options[0],
368450
("ServerAliveInterval".to_string(), "30".to_string())
369451
);
370452
}
453+
454+
#[test]
455+
fn test_shell_escape_command() {
456+
// Single argument
457+
assert_eq!(shell_escape_command(&["echo".to_string()]), "echo");
458+
459+
// Multiple simple arguments
460+
assert_eq!(
461+
shell_escape_command(&["/bin/sh".to_string(), "-c".to_string()]),
462+
"/bin/sh -c"
463+
);
464+
465+
// Arguments with special characters - shlex uses single quotes for POSIX compliance
466+
let result = shell_escape_command(&[
467+
"/bin/sh".to_string(),
468+
"-c".to_string(),
469+
"echo hello; sleep 5; echo world".to_string(),
470+
]);
471+
// Verify that the result properly escapes the command with semicolons
472+
assert!(result.starts_with("/bin/sh -c "));
473+
assert!(result.contains("echo hello; sleep 5; echo world"));
474+
475+
// Test that shlex properly handles quotes and spaces
476+
let result2 = shell_escape_command(&[
477+
"echo".to_string(),
478+
"hello world".to_string(),
479+
"it's working".to_string(),
480+
]);
481+
assert!(result2.contains("hello world"));
482+
assert!(result2.contains("it's working"));
483+
}
371484
}

0 commit comments

Comments
 (0)