Fix SSH issue (-o ClearAllForwardings=yes) in forced flags, harden ssh.rs (block callers own ~/.ssh/config, force every jump to be self-contained); updated test suite
This commit is contained in:
162
src/ssh.rs
162
src/ssh.rs
@@ -1,62 +1,107 @@
|
||||
//! Builds the `ssh` invocation for a profile.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::fmt::Write as _;
|
||||
use std::process::{ Stdio, Command };
|
||||
|
||||
use crate::profile::Profile;
|
||||
|
||||
/// A throwaway `ssh_config` applying the same hardening (and identity, if
|
||||
/// any) to every host, so `-J`'s inner proxy connection picks it up too
|
||||
/// instead of falling back to default identities and prompting for
|
||||
/// host-key confirmation on a `/dev/null`-less stdin. Deleted on drop, so
|
||||
/// callers just need to keep this alive for as long as the `ssh` process
|
||||
/// that reads it runs.
|
||||
pub struct JumpConfig(PathBuf);
|
||||
|
||||
impl JumpConfig
|
||||
{
|
||||
/// Named after the profile and this process's pid rather than
|
||||
/// something unique per call, so a long-lived supervisor's repeated
|
||||
/// reconnect attempts overwrite the same file instead of littering a
|
||||
/// new one on every retry.
|
||||
fn write(profile: &Profile) -> Option<Self>
|
||||
{
|
||||
let mut body = String::from("Host *\n");
|
||||
for (key, value) in forced_options(profile) { writeln!(body, "\t{key} {value}").ok()?; }
|
||||
if let Some(identity) = &profile.identity
|
||||
{
|
||||
writeln!(body, "\tIdentityFile {identity}").ok()?;
|
||||
writeln!(body, "\tIdentitiesOnly yes").ok()?;
|
||||
}
|
||||
|
||||
let path = std::env::temp_dir().join(format!("porthole-{}-{}.sshconfig", profile.name, std::process::id()));
|
||||
std::fs::write(&path, body).ok()?;
|
||||
Some(Self(path))
|
||||
}
|
||||
|
||||
fn arg(&self) -> &str { self.0.to_str().unwrap_or("/dev/null") }
|
||||
}
|
||||
|
||||
impl Drop for JumpConfig
|
||||
{
|
||||
fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); }
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
|
||||
/// The `-o` options forced on every invocation, not user-configurable.
|
||||
/// Shared by `build()` (as command-line `-o key=value` args) and
|
||||
/// `JumpConfig::write` (as `ssh_config` lines), so the two representations
|
||||
/// of "what's forced" can't drift apart.
|
||||
fn forced_options(profile: &Profile) -> [(&'static str, String); 9]
|
||||
{
|
||||
[
|
||||
("BatchMode", "yes".into()),
|
||||
("StrictHostKeyChecking", "accept-new".into()),
|
||||
("LogLevel", "ERROR".into()),
|
||||
("ExitOnForwardFailure", "yes".into()),
|
||||
("ConnectTimeout", "10".into()),
|
||||
("ServerAliveCountMax", "3".into()),
|
||||
("ControlMaster", "no".into()),
|
||||
("ControlPath", "none".into()),
|
||||
("ServerAliveInterval", profile.keepalive.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Builds the `ssh` command for `profile`, stdio wired for the supervisor
|
||||
/// to capture (stdout/stderr piped so failure text can be classified;
|
||||
/// stdin from `/dev/null` since porthole never wants a shell).
|
||||
pub fn build(profile: &Profile) -> Command {
|
||||
/// stdin from `/dev/null` since porthole never wants a shell). The second
|
||||
/// return value, when present, must outlive the spawned process: it owns
|
||||
/// the config file `-F` points at and deletes it on drop.
|
||||
pub fn build(profile: &Profile) -> (Command, Option<JumpConfig>)
|
||||
{
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
// Flags forced on every invocation, not user-configurable.
|
||||
cmd.args([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-o",
|
||||
"ControlMaster=no",
|
||||
"-o",
|
||||
"ControlPath=none",
|
||||
"-o",
|
||||
"ClearAllForwardings=yes",
|
||||
"-o",
|
||||
&format!("ServerAliveInterval={}", profile.keepalive),
|
||||
"-N",
|
||||
"-T",
|
||||
]);
|
||||
|
||||
let (jumps, target) = profile.ssh_target();
|
||||
if let Some(jumps) = jumps {
|
||||
cmd.args(["-J", &jumps]);
|
||||
}
|
||||
|
||||
let jump_config = jumps.is_some().then(|| JumpConfig::write(profile)).flatten();
|
||||
let config_arg = jump_config.as_ref().map_or("/dev/null", |c| c.arg());
|
||||
|
||||
cmd.args(["-F", config_arg]);
|
||||
for (key, value) in forced_options(profile) { cmd.args(["-o", &format!("{key}={value}")]); }
|
||||
cmd.args(["-N", "-T"]);
|
||||
|
||||
if let Some(jumps) = jumps { cmd.args(["-J", &jumps]); }
|
||||
|
||||
cmd.arg("-p").arg(profile.ssh_port.to_string());
|
||||
if let Some(identity) = &profile.identity {
|
||||
if let Some(identity) = &profile.identity
|
||||
{
|
||||
cmd.arg("-i").arg(identity);
|
||||
cmd.args(["-o", "IdentitiesOnly=yes"]);
|
||||
}
|
||||
if let Some(user) = &profile.user {
|
||||
cmd.arg("-l").arg(user);
|
||||
}
|
||||
if let Some(user) = &profile.user { cmd.arg("-l").arg(user); }
|
||||
|
||||
cmd.arg(profile.kind.ssh_flag()).arg(&profile.mapping);
|
||||
cmd.arg(target);
|
||||
|
||||
cmd
|
||||
(cmd, jump_config)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -75,17 +120,50 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_the_callers_own_ssh_config() {
|
||||
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert!(args.windows(2).any(|w| w == ["-F", "/dev/null"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_hop_has_no_dash_j() {
|
||||
let cmd = build(&profile_with(vec!["jumpbox"]));
|
||||
let (cmd, _guard) = build(&profile_with(vec!["jumpbox"]));
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert!(!args.contains(&"-J".to_string()));
|
||||
assert_eq!(args.last(), Some(&"jumpbox".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_hop_writes_a_config_carrying_hardening_and_identity_to_every_hop() {
|
||||
let p = Profile::new(
|
||||
"t2".into(),
|
||||
&ProfileEdits {
|
||||
local: Some("5432:db.internal:5432".into()),
|
||||
via: Some(vec!["bastion1".into(), "bastion2".into()]),
|
||||
identity: Some("/home/me/.ssh/id_ed25519".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (cmd, guard) = build(&p);
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let f_idx = args.iter().position(|a| a == "-F").expect("-F present");
|
||||
let config_path = &args[f_idx + 1];
|
||||
assert_ne!(config_path, "/dev/null");
|
||||
let contents = std::fs::read_to_string(config_path).expect("config file should exist");
|
||||
assert!(contents.contains("BatchMode yes"));
|
||||
assert!(contents.contains("StrictHostKeyChecking accept-new"));
|
||||
assert!(contents.contains("IdentityFile /home/me/.ssh/id_ed25519"));
|
||||
assert!(contents.contains("IdentitiesOnly yes"));
|
||||
drop(guard);
|
||||
assert!(!std::path::Path::new(config_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_hop_splits_jumps_from_target() {
|
||||
let cmd = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||
let (cmd, _guard) = build(&profile_with(vec!["bastion1", "bastion2:2222"]));
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let j_idx = args.iter().position(|a| a == "-J").expect("-J present");
|
||||
assert_eq!(args[j_idx + 1], "bastion1");
|
||||
@@ -96,7 +174,7 @@ mod tests {
|
||||
fn includes_forward_flag_and_mapping() {
|
||||
let p = profile_with(vec!["jumpbox"]);
|
||||
assert_eq!(p.kind, Kind::Local);
|
||||
let cmd = build(&p);
|
||||
let (cmd, _guard) = build(&p);
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let l_idx = args.iter().position(|a| a == "-L").expect("-L present");
|
||||
assert_eq!(args[l_idx + 1], "5432:db.internal:5432");
|
||||
@@ -114,7 +192,8 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let args: Vec<String> = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let (cmd, _guard) = build(&p);
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let i_idx = args.iter().position(|a| a == "-i").expect("-i present");
|
||||
assert_eq!(args[i_idx + 1], "/home/me/.ssh/id_ed25519");
|
||||
assert!(args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||
@@ -124,7 +203,8 @@ mod tests {
|
||||
fn identities_only_absent_without_identity() {
|
||||
let p = profile_with(vec!["jumpbox"]);
|
||||
assert!(p.identity.is_none());
|
||||
let args: Vec<String> = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
let (cmd, _guard) = build(&p);
|
||||
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@ enum Outcome {
|
||||
|
||||
/// Entry point for `porthole __supervise <name>`. This function is the
|
||||
/// supervisor process: it runs until told to stop (SIGTERM/SIGINT) or
|
||||
/// gives up per §4.
|
||||
pub fn run(name: &str) -> Result<()> {
|
||||
/// gives up.
|
||||
pub fn run(name: &str) -> Result<()>
|
||||
{
|
||||
install_signal_handler();
|
||||
|
||||
let profile = profile::load(name)?;
|
||||
@@ -154,13 +155,16 @@ fn sleep_or_shutdown(dur: Duration) -> bool
|
||||
/// requested. Marks `inst` as `State::Up` once the process has survived
|
||||
/// `CONNECT_GRACE`. `ssh` does not report "the forward is bound" directly
|
||||
/// without parsing `-v` debug output; a real failure exits near-instantly
|
||||
/// under `ExitOnForwardFailure=yes` (§3.1), so staying alive past the grace
|
||||
/// under `ExitOnForwardFailure=yes`, so staying alive past the grace
|
||||
/// window is used as a proxy for connected.
|
||||
fn run_ssh_once(name: &str, profile: &Profile, inst: &mut Instance) -> Outcome
|
||||
{
|
||||
rotate_log_if_large(name);
|
||||
|
||||
let mut cmd = ssh::build(profile);
|
||||
// `_jump_config`, when present, must stay alive for this whole
|
||||
// function: it owns the config file `-F` points ssh at, and every
|
||||
// return path below runs the ssh process to completion first.
|
||||
let (mut cmd, _jump_config) = ssh::build(profile);
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Outcome::Failed { class: Class::Unrecognized, message: format!("failed to spawn ssh: {e}") },
|
||||
|
||||
Reference in New Issue
Block a user