use std::path::{Path, PathBuf}; use std::process::Stdio; use anyhow::{Context, Result}; use tokio::process::Child; use crate::config::SshConfig; pub use crate::ssh::Forward; /// What a driver needs run in order to exist as a process. Deliberately just /// what every transport can carry -- the command, where it runs, and a port the /// caller needs to reach; anything a particular machine needs is the /// transport's own configuration, not something a driver states. pub struct Launch { pub program: String, pub args: Vec, pub cwd: Option, /// On the launch rather than in [`Transport::spawn`]'s signature /// because it is part of what is being run: a caller that needs to /// reach the process it is starting says so once, where it says /// everything else about it, and every transport reads it the same /// way. pub forward: Option, } impl Launch { pub fn new(program: impl Into, args: Vec, cwd: Option<&Path>) -> Self { Self { program: program.into(), args, cwd: cwd.map(Path::to_path_buf), forward: None, } } /// Says that this program serves `forward.there`, and that the caller /// will reach it at `forward.here`. pub fn reaching(mut self, forward: Forward) -> Self { self.forward = Some(forward); self } } pub enum Streams { Piped, PipedFrom(Stdio), Detached { stdin: Stdio, stdout: Stdio, stderr: Stdio, }, } pub enum Transport { Here, Ssh { name: String, ssh: SshConfig }, } impl Transport { pub fn for_setup(setup: &crate::config::SetupConfig) -> Self { match &setup.ssh { Some(ssh) => Self::Ssh { name: setup.name.clone(), ssh: ssh.clone(), }, None => Self::Here, } } pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result { let host = match self { Self::Here => None, Self::Ssh { ssh, .. } => Some(ssh), }; let mut command = tokio::process::Command::from(crate::ssh::command( host, &launch.program, &launch.args, launch.cwd.as_deref(), launch.forward, )); match streams { Streams::Piped => { command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); } Streams::PipedFrom(stdin) => { command .stdin(stdin) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); } Streams::Detached { stdin, stdout, stderr, } => { command.stdin(stdin).stdout(stdout).stderr(stderr); command.process_group(0); } } command.spawn().with_context(|| match self { Self::Ssh { name, .. } => format!( "couldn't start ssh to run \"{}\" on {name} -- is the ssh client installed \ here?", launch.program, ), Self::Here => format!( "couldn't run \"{}\" on this machine -- is it installed and on PATH? If it \ lives on another machine, give the session a host to run on.", launch.program, ), }) } /// Runs `launch` to completion and returns its stdout, blocking. The /// synchronous twin of `capture`, for callers already on a blocking task that /// would otherwise need a runtime to ask a machine a question. Both build the /// invocation the same way. pub fn capture_blocking(&self, launch: &Launch) -> Result { let host = match self { Self::Here => None, Self::Ssh { ssh, .. } => Some(ssh), }; let output = crate::ssh::command( host, &launch.program, &launch.args, launch.cwd.as_deref(), launch.forward, ) .output() .with_context(|| format!("couldn't run \"{}\" {}", launch.program, self.describe()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); anyhow::bail!(if stderr.is_empty() { format!("couldn't reach it ({})", output.status) } else { stderr }); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } /// The one description of "run this there, with this on stdin", so that /// shipping an attachment and writing a file through the explorer are the /// same operation rather than two. It is also the only capture that hands /// back the **status**: a script can answer with an exit code the caller /// distinguishes (the explorer's write says `exit 3` for "this file is not /// the one you read"), which [`Transport::capture`] cannot express. /// /// Bytes rather than a `String`, because a file's contents are not text /// until something has checked, and lossy decoding would replace the /// evidence that they are not. pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result { let (streams, to_write) = match input { Input::None => (Streams::Piped, None), Input::Bytes(bytes) => (Streams::Piped, Some(bytes)), Input::File(file) => (Streams::PipedFrom(file.into()), None), }; let mut child = self.spawn(launch, streams)?; if let Some(bytes) = to_write { // Written from a task rather than before the wait, because the child // may not read all of it -- the write script exits without reading // when the file has changed underneath -- and a caller blocked on // filling a pipe nobody is draining would deadlock instead of getting // that answer. The broken pipe is the expected end of this write. let mut stdin = child.stdin.take().context("the child has no stdin")?; tokio::spawn(async move { use tokio::io::AsyncWriteExt; let _ = stdin.write_all(&bytes).await; let _ = stdin.shutdown().await; }); } let output = child .wait_with_output() .await .with_context(|| format!("waiting for \"{}\" {}", launch.program, self.describe()))?; Ok(Captured { status: output.status, stdout: output.stdout, stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), }) } /// Over ssh the near end is chosen the same way and the far end is a /// guess, because there is no portable way to ask a machine for a free /// port that does not race with binding it anyway. It is taken from /// [`FAR_PORTS`], below the range Linux hands out to outgoing /// connections, so a collision means something else deliberately /// listening there. That is not silent: the program fails to bind and /// exits, and `session::llama` reports what its log said rather than /// waiting out its readiness timeout. pub fn reserve_port(&self) -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0") .context("asking this machine for a free port")?; let here = listener.local_addr()?.port(); Ok(match self { Self::Here => Forward { there: here, here }, Self::Ssh { .. } => Forward { there: rand::random_range(FAR_PORTS), here, }, }) } pub fn describe(&self) -> String { match self { Self::Here => "on this machine".to_string(), Self::Ssh { name, ssh } => format!("on {name} ({})", ssh.address), } } } const FAR_PORTS: std::ops::Range = 20000..30000; /// Three cases rather than an `Option` because they are three genuinely /// different arrangements and only this knows which: nothing to say, bytes this /// process is holding, or a file it has open. The last is how a /// several-hundred-megabyte attachment reaches another machine without passing /// through this server's memory. pub enum Input { None, Bytes(Vec), File(std::fs::File), } pub struct Captured { pub status: std::process::ExitStatus, pub stdout: Vec, pub stderr: String, } impl Captured { pub fn ok(self) -> Result> { if self.status.success() { return Ok(self.stdout); } anyhow::bail!(if self.stderr.is_empty() { format!("it failed with no explanation ({})", self.status) } else { self.stderr }) } }