use std::process::Command;
use anyhow::{Context, Result};
use crate::model::{Auth, Credential};
use crate::remote::{self, Host};
use crate::shell::Dialect;
pub const ENV_CLIENT: &str = "TURNOUT_SSH";
pub fn run(name: Option<String>, credential: Option<String>) -> Result<()> {
let host = remote::resolve_host(name, credential)?;
let client = std::env::var(ENV_CLIENT).unwrap_or_else(|_| "ssh".to_string());
let mut args: Vec<String> = vec!["-p".into(), host.server.port.to_string()];
if host.credential.auth == Auth::Key
&& let Some(key) = &host.credential.key
{
args.push("-i".into());
args.push(key.clone());
}
args.push(format!("{}@{}", host.credential.user, host.server.ssh_host()));
if let Some(dir) = &host.dir {
match landing_shell(&host) {
Some(dialect) => {
remote::check_quotable(dialect, &[dir])?;
args.push("-t".into());
args.push(landing_command(dialect, dir));
}
None => eprintln!(
"note: could not tell which shell '{}' runs - the session opens in the home directory",
host.server.name
),
}
}
if let Some(what) = asks_for(&host.credential)
&& let Ok(secret) = crate::secrets::get(&host.credential.name)
{
match crate::clipboard::set(&secret) {
Ok(()) => eprintln!("{what} for '{}' copied to the clipboard - paste it at the prompt.", host.credential.name),
Err(_) => eprintln!("{what} for '{}' is in the keyring: `turnout pass copy {0}`.", host.credential.name),
}
}
eprintln!("[{}] {client} {}", host.label(), args.join(" "));
crate::journal::record("ssh", Some(&host.server.name), None, None);
let mut child = Command::new(&client)
.args(&args)
.spawn()
.with_context(|| format!("cannot run '{client}' - is the OpenSSH client installed? ({ENV_CLIENT} names another one)"))?;
crate::term::child_begin();
let status = child.wait();
crate::term::child_end();
let status = status.with_context(|| format!("cannot wait for '{client}'"))?;
std::process::exit(status.code().unwrap_or(1));
}
fn asks_for(credential: &Credential) -> Option<&'static str> {
match credential.auth {
Auth::Password => Some("Password"),
Auth::Key => credential.key.as_deref().filter(|key| key_is_encrypted(key)).map(|_| "Passphrase"),
Auth::Agent => None,
}
}
fn key_is_encrypted(path: &str) -> bool {
match russh::keys::load_secret_key(path, None) {
Ok(_) => false,
Err(russh::keys::Error::KeyIsEncrypted) => true,
Err(_) => std::fs::read_to_string(path).is_ok_and(|text| text.contains("ENCRYPTED")),
}
}
fn landing_shell(host: &Host) -> Option<Dialect> {
if let Some(known) = host.server.shell {
return Some(known);
}
let session = remote::connect(&host.server, &host.credential).ok()?;
Some(remote::dialect(&session, &host.server))
}
pub fn landing_command(dialect: Dialect, dir: &str) -> String {
match dialect {
Dialect::Posix => format!("cd {} && exec $SHELL -l", dialect.quote(dir)),
Dialect::Windows => format!("cd /d {} && cmd", dialect.quote(dir)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_landing_command_speaks_the_servers_shell() {
assert_eq!(landing_command(Dialect::Posix, "/srv/app"), "cd '/srv/app' && exec $SHELL -l");
assert_eq!(landing_command(Dialect::Windows, r"C:\site"), r#"cd /d "C:\site" && cmd"#);
}
fn credential(auth: Auth, key: Option<&std::path::Path>) -> Credential {
Credential {
name: "deploy".into(),
user: "deploy".into(),
auth,
key: key.map(|path| path.display().to_string()),
}
}
#[test]
fn only_what_the_prompt_asks_for_is_copied() {
use russh::keys::ssh_key::LineEnding;
use russh::keys::ssh_key::private::{Ed25519Keypair, KeypairData};
let scratch = tempfile::tempdir().unwrap();
let key = russh::keys::PrivateKey::new(KeypairData::Ed25519(Ed25519Keypair::from_seed(&[9; 32])), "test").unwrap();
let open = scratch.path().join("id_open");
std::fs::write(&open, key.to_openssh(LineEnding::LF).unwrap().as_bytes()).unwrap();
let locked = scratch.path().join("id_locked");
let encrypted = key.encrypt(&mut rand::rng(), "passphrase").unwrap();
std::fs::write(&locked, encrypted.to_openssh(LineEnding::LF).unwrap().as_bytes()).unwrap();
assert_eq!(asks_for(&credential(Auth::Password, None)), Some("Password"));
assert_eq!(asks_for(&credential(Auth::Key, Some(&locked))), Some("Passphrase"));
assert_eq!(asks_for(&credential(Auth::Key, Some(&open))), None, "a key without a passphrase asks nothing");
assert_eq!(asks_for(&credential(Auth::Key, Some(&scratch.path().join("missing")))), None);
assert_eq!(asks_for(&credential(Auth::Agent, None)), None);
}
#[test]
fn an_unquotable_directory_is_refused() {
assert!(remote::check_quotable(Dialect::Windows, &[r#"C:\odd"dir"#]).is_err());
assert!(remote::check_quotable(Dialect::Posix, &["/srv/it's"]).is_ok());
}
}