From 610571fc703b3dbdb5830f989b291e9d428d1605 Mon Sep 17 00:00:00 2001 From: Overlord Date: Thu, 13 Aug 2026 22:42:02 +0200 Subject: [PATCH] Harden forced ssh flags: accept-new host keys, block multiplexing, tighten identity auth BatchMode=yes already blocked TTY prompts, but a brand-new host with no known_hosts entry failed outright on first connect since ssh had no way to ask for acceptance. Add StrictHostKeyChecking=accept-new (TOFU, still hard-fails on a changed known host) plus LogLevel=ERROR to keep the resulting "permanently added" notice out of profile logs. Also force ControlMaster=no/ControlPath=none and ClearAllForwardings=yes so a user's own ~/.ssh/config can't make porthole's spawned ssh share a multiplexed connection or apply extra forwards - the supervisor's process-based tracking assumes one spawned ssh exclusively owns one tunnel. ServerAliveCountMax=3 makes dead-connection detection time deterministic against the profile's keepalive, and -T is explicit no-pty insurance alongside the existing -N. IdentitiesOnly=yes is added whenever a profile sets an identity file, avoiding auth-failure lockouts from also offering agent/default keys. Co-Authored-By: Claude Sonnet 5 --- src/ssh.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/ssh.rs b/src/ssh.rs index 14eebc0..bd3d14f 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -15,12 +15,25 @@ pub fn build(profile: &Profile) -> Command { "-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(); @@ -31,6 +44,7 @@ pub fn build(profile: &Profile) -> Command { cmd.arg("-p").arg(profile.ssh_port.to_string()); 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); @@ -86,4 +100,30 @@ mod tests { let l_idx = args.iter().position(|a| a == "-L").expect("-L present"); assert_eq!(args[l_idx + 1], "5432:db.internal:5432"); } + + #[test] + fn identities_only_set_when_identity_given() { + let p = Profile::new( + "t".into(), + &ProfileEdits { + local: Some("5432:db.internal:5432".into()), + via: Some(vec!["jumpbox".into()]), + identity: Some("/home/me/.ssh/id_ed25519".into()), + ..Default::default() + }, + ) + .unwrap(); + let args: Vec = build(&p).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"])); + } + + #[test] + fn identities_only_absent_without_identity() { + let p = profile_with(vec!["jumpbox"]); + assert!(p.identity.is_none()); + let args: Vec = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"])); + } }