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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:42:02 +02:00
parent 0aab042859
commit 610571fc70

View File

@@ -15,12 +15,25 @@ pub fn build(profile: &Profile) -> Command {
"-o", "-o",
"BatchMode=yes", "BatchMode=yes",
"-o", "-o",
"StrictHostKeyChecking=accept-new",
"-o",
"LogLevel=ERROR",
"-o",
"ExitOnForwardFailure=yes", "ExitOnForwardFailure=yes",
"-o", "-o",
"ConnectTimeout=10", "ConnectTimeout=10",
"-o", "-o",
"ServerAliveCountMax=3",
"-o",
"ControlMaster=no",
"-o",
"ControlPath=none",
"-o",
"ClearAllForwardings=yes",
"-o",
&format!("ServerAliveInterval={}", profile.keepalive), &format!("ServerAliveInterval={}", profile.keepalive),
"-N", "-N",
"-T",
]); ]);
let (jumps, target) = profile.ssh_target(); 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()); 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.arg("-i").arg(identity);
cmd.args(["-o", "IdentitiesOnly=yes"]);
} }
if let Some(user) = &profile.user { if let Some(user) = &profile.user {
cmd.arg("-l").arg(user); cmd.arg("-l").arg(user);
@@ -86,4 +100,30 @@ mod tests {
let l_idx = args.iter().position(|a| a == "-L").expect("-L present"); let l_idx = args.iter().position(|a| a == "-L").expect("-L present");
assert_eq!(args[l_idx + 1], "5432:db.internal:5432"); 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<String> = 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<String> = build(&p).get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(!args.windows(2).any(|w| w == ["-o", "IdentitiesOnly=yes"]));
}
} }