use std::fmt::Debug;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{env, os::unix::fs::OpenOptionsExt, path::Path, process, sync::Arc, time::Duration};
use base64ct::LineEnding;
use eyre::{Context, Result, eyre};
use russh::keys::PrivateKey;
use russh::keys::ssh_key::private::Ed25519Keypair;
use russh::{ChannelMsg, Disconnect, keys::key::PrivateKeyWithHashAlg};
use serde::{Deserialize, Serialize};
use termion::raw::IntoRawMode;
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::time::Instant;
use tokio_fd::AsyncFd;
use tokio_vsock::{VsockAddr, VsockStream};
use tracing::{debug, info, instrument, warn};
use crate::error::Error;
use crate::runner::CancellationTokens;
use crate::types::{EnvVar, Interactive, Io};
use crate::utils::safe_flush;
pub(crate) const SSH_PRIVKEY_FILENAME: &str = "id_ed25519";
pub const DEFAULT_SSH_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Debug)]
pub(crate) struct PersistedSshKeypair {
pub pubkey_str: String,
pub privkey_str: String,
pub privkey_path: PathBuf,
}
impl PersistedSshKeypair {
fn from_privkey(privkey_str: &str, privkey_path: &Path) -> Result<Self> {
let privkey = PrivateKey::from_openssh(privkey_str)?;
Ok(Self {
pubkey_str: privkey.public_key().to_openssh()?,
privkey_str: privkey_str.to_string(),
privkey_path: privkey_path.to_path_buf(),
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SshLaunchOpts {
#[serde(skip)]
pub privkey: String,
pub tty: bool,
pub interactive: Interactive,
pub timeout: Duration,
pub env_vars: Vec<EnvVar>,
pub workdir: Option<PathBuf>,
pub args: Vec<String>,
pub cid: u32,
pub stdout: Io,
pub stderr: Io,
}
#[derive(Debug)]
pub struct CommandOutput {
pub exit_code: u32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[instrument]
pub(crate) fn ensure_ssh_key(dir: &Path) -> Result<PersistedSshKeypair> {
let privkey_path = dir.join(SSH_PRIVKEY_FILENAME);
if let Ok(privkey_str) = fs::read_to_string(&privkey_path) {
return PersistedSshKeypair::from_privkey(&privkey_str, &privkey_path)
.wrap_err(format!("Couldn't read the SSH key at {privkey_path:?}"));
}
let privkey_str = PrivateKey::from(Ed25519Keypair::random(&mut rand::rng()))
.to_openssh(LineEnding::default())?
.to_string();
let tmp_path = privkey_path.with_extension(format!("tmp.{}", process::id()));
debug!("Writing SSH private key to {privkey_path:?}");
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)
.wrap_err(format!("Couldn't create {tmp_path:?}"))?
.write_all(privkey_str.as_bytes())?;
fs::rename(&tmp_path, &privkey_path)?;
PersistedSshKeypair::from_privkey(&privkey_str, &privkey_path)
}
#[derive(Debug, Clone)]
struct SshClient {}
impl russh::client::Handler for SshClient {
type Error = russh::Error;
#[instrument]
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
pub(crate) struct Session {
session: russh::client::Handle<SshClient>,
tty_state: Pty,
}
enum Pty {
Enabled {
host_terminal_size: (u16, u16),
},
Disabled,
}
impl Pty {
fn is_enabled(&self) -> bool {
match self {
Pty::Enabled { .. } => true,
Pty::Disabled => false,
}
}
}
enum StdinReader {
Enabled { fd: AsyncFd, buf: Vec<u8> },
Closed,
Disabled,
}
impl StdinReader {
async fn maybe_read(&mut self) -> Option<(std::io::Result<usize>, &[u8])> {
match self {
StdinReader::Enabled { fd, buf } => Some((fd.read(buf).await, buf)),
StdinReader::Disabled => None,
StdinReader::Closed => None,
}
}
}
pub(crate) enum ConnectError {
Transient,
Fatal(Error),
}
fn classify_vsock_error(e: &std::io::Error) -> ConnectError {
match e.raw_os_error() {
Some(n) if n == nix::errno::Errno::EAFNOSUPPORT as i32 => {
ConnectError::Fatal(Error::VsockUnavailable)
}
Some(n) if n == nix::errno::Errno::ENODEV as i32 => ConnectError::Transient,
Some(n) if n == nix::errno::Errno::EHOSTUNREACH as i32 => ConnectError::Transient,
_ => match e.kind() {
ErrorKind::TimedOut | ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset => {
ConnectError::Transient
}
_ => ConnectError::Fatal(Error::Other(eyre!(
"Unexpected error connecting to VM: {e}"
))),
},
}
}
fn classify_ssh_error(e: &russh::Error) -> ConnectError {
match e {
russh::Error::IO(e) => match e.kind() {
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset => ConnectError::Transient,
_ => ConnectError::Fatal(Error::Other(eyre!(
"Unexpected error connecting to VM via SSH: {e}"
))),
},
russh::Error::Disconnect => ConnectError::Transient,
e => ConnectError::Fatal(Error::Other(eyre!(
"Unexpected error connecting to VM via SSH: {e}"
))),
}
}
async fn write_session_log(log: &mut Option<&mut tokio::fs::File>, data: &[u8]) {
if let Some(f) = log.as_deref_mut() {
let res = async {
f.write_all(data).await?;
f.flush().await
}
.await;
if let Err(e) = res {
warn!("Failed to write to session log, disabling logging: {e}");
*log = None;
}
}
}
impl Session {
#[instrument(skip(privkey))]
async fn connect(
privkey: PrivateKey,
cid: u32,
port: u32,
timeout: Duration,
allocate_tty: bool,
) -> Result<Self, Error> {
let config = russh::client::Config {
keepalive_interval: Some(Duration::from_secs(5)),
..<_>::default()
};
let config = Arc::new(config);
let sh = SshClient {};
let vsock_addr = VsockAddr::new(cid, port);
let now = Instant::now();
debug!("Connecting to SSH via vsock");
let mut session = loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if now.elapsed() > timeout {
return Err(Error::SshTimeout(timeout));
}
let stream = match VsockStream::connect(vsock_addr).await {
Ok(stream) => stream,
Err(ref e) => match classify_vsock_error(e) {
ConnectError::Transient => continue,
ConnectError::Fatal(e) => return Err(e),
},
};
match russh::client::connect_stream(config.clone(), stream, sh.clone()).await {
Ok(x) => break x,
Err(ref e) => match classify_ssh_error(e) {
ConnectError::Transient => continue,
ConnectError::Fatal(e) => return Err(e),
},
}
};
debug!("Authenticating via SSH");
let auth_res = session
.authenticate_publickey("root", PrivateKeyWithHashAlg::new(Arc::new(privkey), None))
.await
.map_err(eyre::Report::from)?;
if !auth_res.success() {
return Err(Error::Other(eyre!(
"Authentication (with publickey) failed, this can happen if you deleted the automatically generated SSH key in .local/state. \
Try running `vmexec prune` to delete your local warmup images and then try running this command again."
)));
}
let tty_state = if allocate_tty {
Pty::Enabled {
host_terminal_size: termion::terminal_size().wrap_err("Requested a TTY inside the VM, but vmexec doesn't seem to be running in a terminal")?,
}
} else {
Pty::Disabled
};
Ok(Self { session, tty_state })
}
#[instrument(skip(self, session_log))]
async fn call(
&mut self,
interactive: Interactive,
env: Vec<EnvVar>,
command: &str,
mut session_log: Option<&mut tokio::fs::File>,
stdout: Io,
stderr: Io,
) -> Result<CommandOutput> {
let mut channel = self.session.channel_open_session().await?;
if let Pty::Enabled { host_terminal_size } = &self.tty_state {
channel
.request_pty(
true,
&env::var("TERM").unwrap_or("xterm-256color".into()),
host_terminal_size.0 as u32,
host_terminal_size.1 as u32,
0,
0,
&[], )
.await?;
}
for e in env {
channel.set_env(true, e.key, e.value).await?;
}
channel.exec(true, command).await?;
let code;
let mut stdin_reader = match interactive {
Interactive::Always => {
let buf = vec![0; 1024];
let fd = tokio_fd::AsyncFd::try_from(nix::libc::STDIN_FILENO).wrap_err("Requested stdin to be piped to the process inside the VM, but failed to open stdin.")?;
StdinReader::Enabled { fd, buf }
}
Interactive::Never => StdinReader::Disabled,
Interactive::Auto => {
let fd = tokio_fd::AsyncFd::try_from(nix::libc::STDIN_FILENO);
if let Ok(fd) = fd {
let buf = vec![0; 1024];
StdinReader::Enabled { fd, buf }
} else {
StdinReader::Disabled
}
}
};
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let mut stdout: Box<dyn AsyncWrite + Send + Unpin + '_> = match stdout {
Io::Inherit => Box::new(tokio::io::stdout()),
Io::Piped => Box::new(&mut stdout_buf),
Io::Null => Box::new(tokio::io::sink()),
};
let mut stderr: Box<dyn AsyncWrite + Send + Unpin + '_> = match stderr {
Io::Inherit => Box::new(tokio::io::stderr()),
Io::Piped => Box::new(&mut stderr_buf),
Io::Null => Box::new(tokio::io::sink()),
};
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(500)), if self.tty_state.is_enabled() => {
if let Pty::Enabled{host_terminal_size} = &self.tty_state {
let new_terminal_size = termion::terminal_size()?;
if host_terminal_size != &new_terminal_size {
debug!("Terminal size change detected");
self.tty_state = Pty::Enabled { host_terminal_size: new_terminal_size };
channel.window_change(new_terminal_size.0 as u32, new_terminal_size.1 as u32, 0, 0).await?;
}
}
},
Some((read_bytes, buf)) = stdin_reader.maybe_read() => {
match read_bytes {
Ok(0) => {
stdin_reader = StdinReader::Closed;
channel.eof().await?;
},
Ok(n) => channel.data(&buf[..n]).await?,
Err(e) => return Err(e.into()),
};
},
Some(msg) = channel.wait() => {
match msg {
ChannelMsg::Data { ref data } => {
stdout.write_all(data).await?;
safe_flush(&mut stdout).await?;
write_session_log(&mut session_log, data).await;
}
ChannelMsg::ExtendedData { ref data, ext: 1 } => {
stderr.write_all(data).await?;
safe_flush(&mut stderr).await?;
write_session_log(&mut session_log, data).await;
}
ChannelMsg::ExitStatus { exit_status } => {
code = exit_status;
match stdin_reader {
StdinReader::Enabled { .. } => channel.eof().await?,
StdinReader::Closed => {},
StdinReader::Disabled => channel.eof().await?,
};
break;
}
_ => {}
}
},
}
}
drop(stdout);
drop(stderr);
Ok(CommandOutput {
exit_code: code,
stdout: stdout_buf,
stderr: stderr_buf,
})
}
#[instrument(skip(self))]
async fn close(&mut self) -> Result<()> {
self.session
.disconnect(Disconnect::ByApplication, "", "English")
.await?;
Ok(())
}
}
pub(crate) async fn create_ssh_connection(
ssh_launch_opts: &SshLaunchOpts,
) -> Result<Session, Error> {
let privkey =
PrivateKey::from_openssh(ssh_launch_opts.privkey.clone()).map_err(eyre::Report::from)?;
let ssh = Session::connect(
privkey,
ssh_launch_opts.cid,
22,
ssh_launch_opts.timeout,
ssh_launch_opts.tty,
)
.await?;
Ok(ssh)
}
#[instrument(skip(ssh_launch_opts))]
pub(crate) async fn connect_ssh_for_warmup(
qemu_should_exit: Arc<AtomicBool>,
ssh_launch_opts: SshLaunchOpts,
) -> Result<()> {
let mut ssh = create_ssh_connection(&ssh_launch_opts).await?;
info!("Connected");
let is_running_exitcode = ssh
.call(
Interactive::Never,
vec![],
"systemctl is-system-running --wait --quiet",
None,
Io::Inherit,
Io::Inherit,
)
.await?;
debug!(
"systemctl is-system-running --wait exit code {}",
is_running_exitcode.exit_code
);
ssh.call(
Interactive::Never,
vec![],
"echo 127.0.0.1 unknown >> /etc/hosts",
None,
Io::Inherit,
Io::Inherit,
)
.await?;
ssh.call(
Interactive::Never,
vec![],
"echo AcceptEnv * >> /etc/ssh/sshd_config",
None,
Io::Inherit,
Io::Inherit,
)
.await?;
ssh.call(
Interactive::Never,
vec![],
"systemctl poweroff",
None,
Io::Inherit,
Io::Inherit,
)
.await?;
debug!("Shutting down system");
qemu_should_exit.store(true, Ordering::SeqCst);
let _ = ssh.close().await;
Ok(())
}
#[instrument(skip(cancellation_tokens, ssh_launch_opts))]
pub(crate) async fn connect_ssh_for_command(
cancellation_tokens: Option<CancellationTokens>,
ssh_launch_opts: SshLaunchOpts,
session_log_path: PathBuf,
) -> Result<Option<CommandOutput>> {
let mut ssh = create_ssh_connection(&ssh_launch_opts)
.await
.inspect_err(|_| {
if let Some(cancel_tokens) = cancellation_tokens.clone() {
cancel_tokens.qemu.cancel();
}
})?;
info!("Connected via SSH");
let mut session_log = Some(
tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&session_log_path)
.await
.wrap_err(format!("Couldn't open session log at {session_log_path:?}"))?,
);
let output = {
let _raw_term = if ssh_launch_opts.tty {
Some(std::io::stdout().into_raw_mode()?)
} else {
None
};
let escaped_args = &ssh_launch_opts
.args
.into_iter()
.map(|x| shell_escape::escape(x.into()))
.collect::<Vec<_>>()
.join(" ");
let escaped_args = if let Some(workdir) = ssh_launch_opts.workdir {
let w = workdir.to_string_lossy();
format!("cd {w} && {escaped_args}")
} else {
escaped_args.to_string()
};
if let Some(ref cancel_tokens) = cancellation_tokens {
let ssh_output = tokio::select! {
_ = cancel_tokens.ssh.cancelled() => {
debug!("SSH task was cancelled");
return Ok(None)
}
val = ssh.call(ssh_launch_opts.interactive, ssh_launch_opts.env_vars, &escaped_args, session_log.as_mut(), ssh_launch_opts.stdout, ssh_launch_opts.stderr) => {
val
}
};
cancel_tokens.qemu.cancel();
ssh_output?
} else {
let ssh_output = tokio::select! {
val = ssh.call(ssh_launch_opts.interactive, ssh_launch_opts.env_vars, &escaped_args, session_log.as_mut(), ssh_launch_opts.stdout, ssh_launch_opts.stderr) => {
val
}
};
ssh_output?
}
};
info!("Exit code: {:?}", output.exit_code);
if let Some(cancel_tokens) = cancellation_tokens {
cancel_tokens.qemu.cancel();
}
ssh.close().await?;
Ok(Some(output))
}