diff --git a/crates/bssh-russh/src/cipher/mod.rs b/crates/bssh-russh/src/cipher/mod.rs index 508c2659..662d9974 100644 --- a/crates/bssh-russh/src/cipher/mod.rs +++ b/crates/bssh-russh/src/cipher/mod.rs @@ -345,10 +345,12 @@ pub(crate) async fn read( .len() .checked_sub(padding_length) .ok_or(Error::IndexOutOfBounds)?; + let payload_len = plaintext_end.saturating_sub(PADDING_LENGTH_LEN); // Sequence numbers are on 32 bits and wrap. // https://tools.ietf.org/html/rfc4253#section-6.4 buffer.seqn += Wrapping(1); + buffer.bytes = buffer.bytes.saturating_add(payload_len); buffer.len = 0; // Remove the padding diff --git a/crates/bssh-russh/src/client/mod.rs b/crates/bssh-russh/src/client/mod.rs index 6431237a..58ea2eb1 100644 --- a/crates/bssh-russh/src/client/mod.rs +++ b/crates/bssh-russh/src/client/mod.rs @@ -40,7 +40,6 @@ use std::convert::TryInto; use std::num::Wrapping; use std::pin::Pin; use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use futures::Future; @@ -1315,6 +1314,9 @@ impl Session { // application output in its bounded receivers while a channel is // window-blocked. let can_receive_outbound = !self.kex.active() && !self.common.has_any_pending_data(); + let rekey_timer = + crate::future_or_pending(self.rekey_time_remaining(), tokio::time::sleep); + pin!(rekey_timer); tokio::select! { r = &mut reading => { let (stream_read, mut buffer, mut opening_cipher) = match r { @@ -1336,14 +1338,26 @@ impl Session { result = self.process_disconnect(&pkt).map_err(H::Error::from); } else { self.common.received_data = true; + let kex_was_active = self.kex.active(); reply(self, handler, kex_done_signal, &mut pkt).await?; buffer.seqn = pkt.seqn; // TODO reply changes seqn internall, find cleaner way + + if kex_was_active && !self.kex.active() { + buffer.bytes = 0; + } else if self.read_rekey_limit_reached(buffer.bytes) { + debug!("rekey limit reached after {} inbound bytes", buffer.bytes); + self.initiate_rekey()?; + } } } std::mem::swap(&mut opening_cipher, &mut self.common.remote_to_local); reading.set(start_reading(stream_read, buffer, opening_cipher)); } + () = &mut rekey_timer => { + debug!("rekey time limit reached"); + self.initiate_rekey()?; + } () = &mut keepalive_timer => { if let Some(ref mut enc) = self.common.encrypted { if matches!(enc.state, EncryptedState::Authenticated) { @@ -1739,6 +1753,34 @@ impl Session { Ok(()) } + fn read_rekey_limit_reached(&self, read_bytes: usize) -> bool { + !self.kex.active() + && self + .common + .encrypted + .as_ref() + .is_some_and(|enc| matches!(enc.state, EncryptedState::Authenticated)) + && read_bytes >= self.common.config.limits.rekey_read_limit + } + + fn rekey_time_remaining(&self) -> Option { + if self.kex.active() { + return None; + } + + let enc = self.common.encrypted.as_ref()?; + if !matches!(enc.state, EncryptedState::Authenticated) { + return None; + } + + let limit = self.common.config.limits.rekey_time_limit; + if limit == Duration::MAX { + return None; + } + + Some(limit.saturating_sub(Instant::now().duration_since(enc.last_rekey))) + } + /// Flush the temporary cleartext buffer into the encryption /// buffer. This does *not* flush to the socket. fn flush(&mut self) -> Result<(), crate::Error> { @@ -2022,6 +2064,50 @@ mod tests { ); } + #[test] + fn automatic_rekey_starts_at_inbound_read_limit() { + let (mut session, sender, _replies) = keyboard_interactive_session(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new( + 1 << 30, + 16, + std::time::Duration::from_secs(3600), + ); + let encrypted = session.common.encrypted.as_mut().unwrap(); + encrypted.state = EncryptedState::Authenticated; + encrypted.kex = KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + drop(sender); + + assert!(!session.read_rekey_limit_reached(15)); + assert!(session.read_rekey_limit_reached(16)); + session.initiate_rekey().unwrap(); + + assert!(session.kex.active()); + } + + #[tokio::test] + async fn automatic_rekey_deadline_wakes_an_idle_authenticated_session() { + let (mut session, sender, _replies) = keyboard_interactive_session(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new(1 << 30, 1 << 30, Duration::ZERO); + let encrypted = session.common.encrypted.as_mut().unwrap(); + encrypted.state = EncryptedState::Authenticated; + encrypted.kex = KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + drop(sender); + + let remaining = session + .rekey_time_remaining() + .expect("authenticated sessions with a finite limit need a deadline"); + tokio::time::timeout(Duration::from_millis(50), tokio::time::sleep(remaining)) + .await + .expect("an expired rekey deadline must wake without transport activity"); + session.initiate_rekey().unwrap(); + + assert!(session.kex.active()); + } + #[cfg(feature = "flate2")] fn authenticated_session() -> Session { let config = Arc::new(Config::default()); diff --git a/crates/bssh-russh/src/client/test.rs b/crates/bssh-russh/src/client/test.rs index 3aef6921..eaaf53b2 100644 --- a/crates/bssh-russh/src/client/test.rs +++ b/crates/bssh-russh/src/client/test.rs @@ -2,6 +2,7 @@ mod tests { use std::collections::HashMap; use std::sync::{Arc, Mutex}; + use std::sync::atomic::{AtomicUsize, Ordering}; use log::debug; use ssh_key::PrivateKey; @@ -83,6 +84,31 @@ mod tests { } } + struct CountingClient { + kex_count: Arc, + } + + impl Handler for CountingClient { + type Error = Error; + + async fn check_server_key( + &mut self, + _: &PublicKeyOrCertificate, + ) -> Result { + Ok(true) + } + + async fn kex_done( + &mut self, + _: Option<&[u8]>, + _: &crate::negotiation::Names, + _: &mut crate::client::Session, + ) -> Result<(), Self::Error> { + self.kex_count.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + } + #[tokio::test] async fn test_client_connects_to_protocol_1_99() { let _ = env_logger::try_init(); @@ -164,4 +190,109 @@ mod tests { msg => panic!("Unexpected message {msg:?}"), } } + + #[tokio::test] + async fn automatic_rekey_repeated_bidirectional_transfers_complete() { + let client_key = PrivateKey::random(&mut rng(), ssh_key::Algorithm::Ed25519).unwrap(); + + let mut server_config = server::Config::default(); + server_config.auth_rejection_time = std::time::Duration::from_millis(1); + server_config.inactivity_timeout = None; + server_config.limits = crate::Limits::new( + 8 * 1024, + 8 * 1024, + std::time::Duration::from_secs(3600), + ); + server_config + .keys + .push(PrivateKey::random(&mut rng(), ssh_key::Algorithm::Ed25519).unwrap()); + let server_config = Arc::new(server_config); + + let socket = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = socket.local_addr().unwrap(); + let server = TestServer { + clients: Arc::new(Mutex::new(HashMap::new())), + id: 0, + }; + let server_task = tokio::spawn(async move { + let (socket, _) = socket.accept().await.unwrap(); + let running = server::run_stream(server_config, socket, server) + .await + .unwrap(); + running.await + }); + + let mut client_config = Config::default(); + client_config.limits = crate::Limits::new( + 8 * 1024, + 8 * 1024, + std::time::Duration::from_secs(3600), + ); + let kex_count = Arc::new(AtomicUsize::new(0)); + + tokio::time::timeout(std::time::Duration::from_secs(20), async { + let mut session = connect( + Arc::new(client_config), + addr, + CountingClient { + kex_count: Arc::clone(&kex_count), + }, + ) + .await + .unwrap(); + let authenticated = session + .authenticate_publickey( + "rekey-user", + PrivateKeyWithHashAlg::new( + Arc::new(client_key), + session.best_supported_rsa_hash().await.unwrap().flatten(), + ), + ) + .await + .unwrap(); + assert!(authenticated.success()); + let kex_count_before_transfer = kex_count.load(Ordering::Relaxed); + + let mut channel = session.channel_open_session().await.unwrap(); + for round in 0_u8..6 { + let payload = vec![round; 32 * 1024]; + channel.data(&payload[..]).await.unwrap(); + let mut echoed = Vec::with_capacity(payload.len()); + while echoed.len() < payload.len() { + match channel.wait().await.unwrap() { + crate::channels::ChannelMsg::Data { data } => { + echoed.extend_from_slice(&data) + } + message => panic!("unexpected message during rekey transfer: {message:?}"), + } + } + assert!(echoed == payload, "echo mismatch in rekey round {round}"); + } + + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while kex_count.load(Ordering::Relaxed) < kex_count_before_transfer + 2 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("byte thresholds must complete repeated rekeys during transfer"); + assert!( + kex_count.load(Ordering::Relaxed) >= kex_count_before_transfer + 2, + "six threshold-crossing rounds must observe repeated key exchanges" + ); + + session + .disconnect(crate::Disconnect::ByApplication, "test complete", "") + .await + .unwrap(); + }) + .await + .expect("repeated bidirectional rekeys must not stall"); + + tokio::time::timeout(std::time::Duration::from_secs(2), server_task) + .await + .expect("server session must stop after client disconnect") + .unwrap() + .unwrap(); + } } diff --git a/crates/bssh-russh/src/server/session.rs b/crates/bssh-russh/src/server/session.rs index 49237346..32261b55 100644 --- a/crates/bssh-russh/src/server/session.rs +++ b/crates/bssh-russh/src/server/session.rs @@ -710,6 +710,9 @@ impl Session { } } + let rekey_timer = + future_or_pending(self.rekey_time_remaining(), tokio::time::sleep); + pin!(rekey_timer); tokio::select! { r = &mut reading => { let (stream_read, mut buffer, mut opening_cipher) = match r { @@ -732,6 +735,7 @@ impl Session { } Some(_) => { self.common.received_data = true; + let kex_was_active = self.kex.active(); // TODO it'd be cleaner to just pass cipher to reply() std::mem::swap(&mut opening_cipher, &mut self.common.remote_to_local); @@ -742,10 +746,21 @@ impl Session { buffer.seqn = pkt.seqn; // TODO reply changes seqn internall, find cleaner way std::mem::swap(&mut opening_cipher, &mut self.common.remote_to_local); + + if kex_was_active && !self.kex.active() { + buffer.bytes = 0; + } else if self.read_rekey_limit_reached(buffer.bytes) { + debug!("rekey limit reached after {} inbound bytes", buffer.bytes); + self.initiate_rekey()?; + } } } reading.set(start_reading(stream_read, buffer, opening_cipher)); } + () = &mut rekey_timer => { + debug!("rekey time limit reached"); + self.initiate_rekey()?; + } () = &mut keepalive_timer => { self.common.alive_timeouts = self.common.alive_timeouts.saturating_add(1); if self.common.config.keepalive_max != 0 && self.common.alive_timeouts > self.common.config.keepalive_max { @@ -876,21 +891,81 @@ impl Session { /// Flush the session, i.e. encrypt the pending buffer. pub fn flush(&mut self) -> Result<(), Error> { + let mut start_rekey = false; if let Some(ref mut enc) = self.common.encrypted { if enc.flush( &self.common.config.as_ref().limits, &mut self.common.packet_writer, )? && self.kex == SessionKexState::Idle + && Self::automatic_rekey_eligible(&enc.state) { debug!("starting rekeying"); if enc.exchange.take().is_some() { - self.begin_rekey()?; + // USERAUTH_SUCCESS was just flushed with the pre-authentication + // compression state. If automatic rekeying is the first + // post-authentication traffic, activate delayed server + // compression before emitting KEXINIT, just as the next peer + // packet would normally do. + if matches!(enc.state, EncryptedState::InitCompression) { + if enc.server_compression.is_deferred() { + enc.server_compression + .init_compress(self.common.packet_writer.compress()); + } + enc.state = EncryptedState::Authenticated; + } + start_rekey = true; } } } + if start_rekey { + self.begin_rekey()?; + } Ok(()) } + fn automatic_rekey_eligible(state: &EncryptedState) -> bool { + matches!( + state, + EncryptedState::InitCompression | EncryptedState::Authenticated + ) + } + + fn initiate_rekey(&mut self) -> Result<(), Error> { + if let Some(ref mut enc) = self.common.encrypted { + enc.rekey_wanted = true; + self.flush()?; + } + Ok(()) + } + + fn read_rekey_limit_reached(&self, read_bytes: usize) -> bool { + !self.kex.active() + && self + .common + .encrypted + .as_ref() + .is_some_and(|enc| Self::automatic_rekey_eligible(&enc.state)) + && read_bytes >= self.common.config.limits.rekey_read_limit + } + + fn rekey_time_remaining(&self) -> Option { + if self.kex.active() { + return None; + } + + let enc = self.common.encrypted.as_ref()?; + if !Self::automatic_rekey_eligible(&enc.state) { + return None; + } + + let limit = self.common.config.limits.rekey_time_limit; + if limit == std::time::Duration::MAX { + return None; + } + + Some(limit.saturating_sub(Instant::now().duration_since(enc.last_rekey))) + } + pub fn flush_pending(&mut self, channel: ChannelId) -> Result { if let Some(ref mut enc) = self.common.encrypted { enc.flush_pending(channel) @@ -1504,8 +1579,11 @@ mod tests { use std::num::Wrapping; use std::sync::Arc; + use ssh_encoding::Encode; + use super::*; - use crate::compression::{Compression, Decompress}; + use crate::auth::{AuthRequest, MethodSet}; + use crate::compression::{Compress, Compression, Decompress}; use crate::kex::{KEXES, NONE, SessionKexState}; use crate::session::{CommonSession, Encrypted, EncryptedState, Exchange}; use crate::sshbuffer::{IncomingSshPacket, PacketWriter, SSHBuffer}; @@ -1517,6 +1595,24 @@ mod tests { type Error = crate::Error; } + struct AcceptNoneHandler; + + impl crate::server::Handler for AcceptNoneHandler { + type Error = crate::Error; + + async fn auth_none(&mut self, _user: &str) -> Result { + Ok(Auth::Accept) + } + } + + fn none_auth_request(user: &str) -> Vec { + let mut packet = vec![crate::msg::USERAUTH_REQUEST]; + user.encode(&mut packet).unwrap(); + "ssh-connection".encode(&mut packet).unwrap(); + "none".encode(&mut packet).unwrap(); + packet + } + fn authenticated_session() -> Session { let config = Arc::new(crate::server::Config::default()); let (priority_sender, priority_receiver) = tokio::sync::mpsc::unbounded_channel(); @@ -1619,4 +1715,142 @@ mod tests { matches!(err, crate::Error::PacketSize(len) if len > crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN) ); } + + #[test] + fn automatic_rekey_server_starts_at_inbound_read_limit() { + let mut session = authenticated_session(); + session.common.encrypted.as_mut().unwrap().kex = + KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new( + 1 << 30, + 16, + std::time::Duration::from_secs(3600), + ); + + assert!(!session.read_rekey_limit_reached(15)); + assert!(session.read_rekey_limit_reached(16)); + session.initiate_rekey().unwrap(); + + assert!(session.kex.active()); + } + + #[test] + fn automatic_rekey_server_waits_until_authentication_completes() { + let mut session = authenticated_session(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new( + 0, + 0, + std::time::Duration::from_secs(3600), + ); + let encrypted = session.common.encrypted.as_mut().unwrap(); + encrypted.state = EncryptedState::WaitingAuthServiceRequest { + accepted: false, + sent: false, + }; + encrypted.kex = KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + encrypted.server_compression = Compression::ZlibOpenSSH; + + session.flush().unwrap(); + assert!( + !session.kex.active(), + "a pre-authentication server write limit must not start rekeying" + ); + + session.common.encrypted.as_mut().unwrap().state = EncryptedState::InitCompression; + session.flush().unwrap(); + assert!( + session.kex.active(), + "the same server write limit must start rekeying after auth succeeds" + ); + assert!( + matches!( + session.common.encrypted.as_ref().unwrap().state, + EncryptedState::Authenticated + ), + "automatic rekey must complete the delayed-compression state transition" + ); + assert!( + matches!(session.common.packet_writer.compress(), Compress::Zlib(_)), + "KEXINIT after auth success must use delayed server compression" + ); + } + + #[tokio::test] + async fn automatic_rekey_server_auth_success_arms_idle_deadline_before_next_packet() { + let mut session = authenticated_session(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new(1 << 30, 1 << 30, std::time::Duration::ZERO); + let encrypted = session.common.encrypted.as_mut().unwrap(); + encrypted.state = + EncryptedState::WaitingAuthRequest(AuthRequest::server(MethodSet::server_supported())); + encrypted.kex = KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + encrypted.server_compression = Compression::ZlibOpenSSH; + + session + .process_packet(&mut AcceptNoneHandler, &none_auth_request("idle-user")) + .await + .unwrap(); + + assert!(matches!( + session.common.encrypted.as_ref().unwrap().state, + EncryptedState::InitCompression + )); + assert!(matches!( + session.common.packet_writer.compress(), + Compress::None + )); + let remaining = session + .rekey_time_remaining() + .expect("auth success must arm the finite rekey deadline before another peer packet"); + tokio::time::timeout( + std::time::Duration::from_millis(50), + tokio::time::sleep(remaining), + ) + .await + .expect("the idle post-authentication rekey deadline must wake"); + + session.initiate_rekey().unwrap(); + + assert!(session.kex.active()); + assert!(matches!( + session.common.encrypted.as_ref().unwrap().state, + EncryptedState::Authenticated + )); + assert!(matches!( + session.common.packet_writer.compress(), + Compress::Zlib(_) + )); + } + + #[tokio::test] + async fn automatic_rekey_server_deadline_wakes_an_idle_authenticated_session() { + let mut session = authenticated_session(); + session.common.encrypted.as_mut().unwrap().kex = + KEXES.get(&crate::kex::CURVE25519).unwrap().make(); + Arc::get_mut(&mut session.common.config) + .expect("test session owns its config") + .limits = crate::Limits::new( + 1 << 30, + 1 << 30, + std::time::Duration::ZERO, + ); + + let remaining = session + .rekey_time_remaining() + .expect("authenticated sessions with a finite limit need a deadline"); + tokio::time::timeout( + std::time::Duration::from_millis(50), + tokio::time::sleep(remaining), + ) + .await + .expect("an expired rekey deadline must wake without transport activity"); + session.initiate_rekey().unwrap(); + + assert!(session.kex.active()); + } } diff --git a/crates/bssh-russh/src/sshbuffer.rs b/crates/bssh-russh/src/sshbuffer.rs index 234693ab..fc9980b3 100644 --- a/crates/bssh-russh/src/sshbuffer.rs +++ b/crates/bssh-russh/src/sshbuffer.rs @@ -187,6 +187,38 @@ fn test_write_packet_matches_clear_cipher_write_output() { assert_eq!(writer.buffer().seqn, Wrapping(1)); } +#[test] +fn installing_a_cipher_starts_a_new_write_key_epoch() { + let mut writer = PacketWriter::clear(); + writer.packet_raw(b"initial key exchange").unwrap(); + assert_ne!(writer.buffer().bytes, 0); + + writer.set_cipher(Box::new(cipher::clear::Key {})); + + assert_eq!(writer.buffer().bytes, 0); + writer.packet_raw(b"encrypted epoch").unwrap(); + assert_eq!(writer.buffer().bytes, b"encrypted epoch".len()); +} + +#[tokio::test] +async fn automatic_rekey_read_counter_tracks_payload_bytes() { + let payload = b"inbound rekey accounting"; + let mut writer = PacketWriter::clear(); + writer.packet_raw(payload).unwrap(); + let encoded = writer.buffer().buffer.clone(); + let (mut reader, mut sender) = tokio::io::duplex(encoded.len()); + sender.write_all(&encoded).await.unwrap(); + drop(sender); + + let mut buffer = SSHBuffer::new(); + let mut opening_key = cipher::clear::Key {}; + cipher::read(&mut reader, &mut buffer, &mut opening_key) + .await + .unwrap(); + + assert_eq!(buffer.bytes, payload.len()); +} + #[test] fn test_write_packet_restores_output_buffer_on_error() { let mut writer = PacketWriter::clear(); @@ -290,7 +322,7 @@ fn packet_bytes_compressed_matches_packet_output() { pub struct SSHBuffer { pub buffer: Vec, pub len: usize, // next packet length. - pub bytes: usize, // total bytes written since the last rekey + pub bytes: usize, // total payload bytes read/written since the last rekey // Sequence numbers are on 32 bits and wrap. // https://tools.ietf.org/html/rfc4253#section-6.4 pub seqn: Wrapping, @@ -557,6 +589,10 @@ impl PacketWriter { pub fn set_cipher(&mut self, cipher: Box) { self.cipher = cipher; + // Every cipher installation starts a new key epoch. In particular, + // discard the cleartext KEX packets counted before the initial keys + // were installed as well as the previous encrypted epoch on rekey. + self.write_buffer.bytes = 0; } pub fn reset_seqn(&mut self) { diff --git a/src/executor/parallel.rs b/src/executor/parallel.rs index 948a23d6..13c0edaf 100644 --- a/src/executor/parallel.rs +++ b/src/executor/parallel.rs @@ -1238,6 +1238,10 @@ impl ParallelExecutor { let semaphore = Arc::new(Semaphore::new(self.max_parallel)); let raw_stream = output_mode.is_raw_stream(); + // A single OpenSSH-compatible destination owns piped stdin. Never let + // parallel fan-out race multiple readers over the process input, and + // leave terminal input to the interactive/PTY path. + let forward_stdin = raw_stream && self.nodes.len() == 1 && !std::io::stdin().is_terminal(); let mut manager = MultiNodeStreamManager::new(); let mut handles = Vec::new(); let stdin_is_terminal = std::io::stdin().is_terminal(); @@ -1364,10 +1368,24 @@ impl ParallelExecutor { } } } else { - match client - .connect_and_execute_with_output_streaming(&command, &config, tx.clone()) - .await - { + let execution = if forward_stdin { + client + .connect_and_execute_with_output_streaming_and_stdin( + &command, + &config, + tx.clone(), + ) + .await + } else { + client + .connect_and_execute_with_output_streaming( + &command, + &config, + tx.clone(), + ) + .await + }; + match execution { Ok(exit_status) => { tracing::debug!( "Command completed for {}: exit code {}", diff --git a/src/jump/chain.rs b/src/jump/chain.rs index 3c9eab52..30183cde 100644 --- a/src/jump/chain.rs +++ b/src/jump/chain.rs @@ -510,6 +510,7 @@ fn intermediate_jump_hop_context(jump_host: &JumpHost, hop: usize) -> String { mod tests { use super::*; use crate::ssh::SshConfig; + use crate::ssh::ssh_config::{RekeyDataLimit, RekeyLimit, RekeyTimeLimit}; use crate::ssh::tokio_client::AddressFamily; #[test] @@ -573,6 +574,7 @@ Host bastion Compression yes ServerAliveInterval 11 ServerAliveCountMax 2 + RekeyLimit 4K 30s Host target-alias HostName effective-target @@ -580,12 +582,14 @@ Host target-alias Compression no ServerAliveInterval 22 ServerAliveCountMax 4 + RekeyLimit 8K 60s Host effective-target AddressFamily inet Compression yes ServerAliveInterval 99 ServerAliveCountMax 9 + RekeyLimit 16K 90s "#, ) .expect("valid ssh_config"); @@ -601,11 +605,26 @@ Host effective-target assert!(bastion.compression); assert_eq!(bastion.keepalive_interval, Some(11)); assert_eq!(bastion.keepalive_max, 2); + assert_eq!( + bastion.rekey_limit, + RekeyLimit { + data: RekeyDataLimit::Bytes(4 * 1024), + time: RekeyTimeLimit::Seconds(30), + } + ); let target = chain.destination_connection_config(); assert_eq!(target.address_family, AddressFamily::V6); assert!(!target.compression); assert_eq!(target.keepalive_interval, Some(22)); assert_eq!(target.keepalive_max, 4); + assert_eq!( + target.rekey_limit, + RekeyLimit { + data: RekeyDataLimit::Bytes(8 * 1024), + time: RekeyTimeLimit::Seconds(60), + }, + "the destination must retain its original alias policy instead of re-resolving HostName" + ); } } diff --git a/src/ssh/client/command.rs b/src/ssh/client/command.rs index 80c7e7ee..ea715362 100644 --- a/src/ssh/client/command.rs +++ b/src/ssh/client/command.rs @@ -254,6 +254,28 @@ impl SshClient { command: &str, config: &ConnectionConfig<'_>, output_sender: Sender, + ) -> Result { + self.connect_and_execute_with_output_streaming_inner(command, config, output_sender, false) + .await + } + + /// Execute a command with byte-transparent stdout/stderr and piped stdin. + pub async fn connect_and_execute_with_output_streaming_and_stdin( + &mut self, + command: &str, + config: &ConnectionConfig<'_>, + output_sender: Sender, + ) -> Result { + self.connect_and_execute_with_output_streaming_inner(command, config, output_sender, true) + .await + } + + async fn connect_and_execute_with_output_streaming_inner( + &mut self, + command: &str, + config: &ConnectionConfig<'_>, + output_sender: Sender, + forward_stdin: bool, ) -> Result { tracing::debug!("Connecting to {}:{}", self.host, self.port); @@ -305,6 +327,7 @@ impl SshClient { config.timeout_seconds, output_sender, config.session_policy, + forward_stdin, ) .await } @@ -321,14 +344,22 @@ impl SshClient { command: &str, output_sender: Sender, session_policy: Option<&crate::ssh::SessionPolicy>, + forward_stdin: bool, ) -> Result { match session_policy { + Some(policy) if forward_stdin => { + client + .execute_session_streaming_with_stdin(policy, output_sender) + .await + } Some(policy) => { client .execute_session_streaming(policy, output_sender) .await } - None => client.execute_streaming(command, output_sender).await, + None => { + Self::execute_streaming_once(client, command, output_sender, forward_stdin).await + } } } @@ -340,12 +371,19 @@ impl SshClient { timeout_seconds: Option, output_sender: Sender, session_policy: Option<&crate::ssh::SessionPolicy>, + forward_stdin: bool, ) -> Result { if let Some(timeout_secs) = timeout_seconds { if timeout_secs == 0 { // No timeout (unlimited) tracing::debug!("Executing command with streaming, no timeout (unlimited)"); - Self::execute_resolved_session_streaming(client, command, output_sender, session_policy) + Self::execute_resolved_session_streaming( + client, + command, + output_sender, + session_policy, + forward_stdin, + ) .await .with_context(|| format!("Failed to execute command '{}' on {}:{}. The SSH connection was successful but the command could not be executed.", command, self.host, self.port)) } else { @@ -357,7 +395,13 @@ impl SshClient { ); tokio::time::timeout( command_timeout, - Self::execute_resolved_session_streaming(client, command, output_sender, session_policy) + Self::execute_resolved_session_streaming( + client, + command, + output_sender, + session_policy, + forward_stdin, + ) ) .await .with_context(|| format!("Command execution timeout: The command '{}' did not complete within {} seconds on {}:{}", command, timeout_secs, self.host, self.port))? @@ -369,7 +413,13 @@ impl SshClient { tracing::debug!("Executing command with streaming, default timeout of 300 seconds"); tokio::time::timeout( command_timeout, - Self::execute_resolved_session_streaming(client, command, output_sender, session_policy) + Self::execute_resolved_session_streaming( + client, + command, + output_sender, + session_policy, + forward_stdin, + ) ) .await .with_context(|| format!("Command execution timeout: The command '{}' did not complete within 5 minutes on {}:{}", command, self.host, self.port))? @@ -377,6 +427,21 @@ impl SshClient { } } + async fn execute_streaming_once( + client: &crate::ssh::tokio_client::Client, + command: &str, + output_sender: Sender, + forward_stdin: bool, + ) -> Result { + if forward_stdin { + client + .execute_streaming_with_stdin(command, output_sender) + .await + } else { + client.execute_streaming(command, output_sender).await + } + } + /// Execute a command with sudo password support and streaming output. /// /// This method handles automatic sudo password injection when sudo prompts are detected diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index eb8ecece..1d619c56 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -30,6 +30,7 @@ mod match_directive; mod parser; mod path; mod pattern; +mod rekey; mod resolver; #[cfg(test)] mod resolver_tests; @@ -40,6 +41,9 @@ mod types; // Re-export public types pub use ip_qos::{IpQosParseError, IpQosPolicy, IpQosValue}; +pub use rekey::{ + RUSSH_REKEY_BYTE_CEILING, RekeyDataLimit, RekeyLimit, RekeyLimitParseError, RekeyTimeLimit, +}; pub use types::SshHostConfig; /// SSH configuration parser and resolver @@ -635,7 +639,13 @@ Host backup-server bulk: IpQosValue::None, }) ); - assert_eq!(host1.rekey_limit, Some("1G 1h".to_string())); + assert_eq!( + host1.rekey_limit, + Some(RekeyLimit { + data: RekeyDataLimit::Bytes(1 << 30), + time: RekeyTimeLimit::Seconds(3_600), + }) + ); // Verify backup-server config let host2 = &config.hosts[1]; @@ -647,7 +657,13 @@ Host backup-server bulk: IpQosValue::Class(0x48), }) ); - assert_eq!(host2.rekey_limit, Some("default none".to_string())); + assert_eq!( + host2.rekey_limit, + Some(RekeyLimit { + data: RekeyDataLimit::Default, + time: RekeyTimeLimit::None, + }) + ); } #[test] @@ -740,7 +756,13 @@ Host web1.example.com ); // RekeyLimit should be from web1.example.com (most specific) - assert_eq!(host_config.rekey_limit, Some("1G 2h".to_string())); + assert_eq!( + host_config.rekey_limit, + Some(RekeyLimit { + data: RekeyDataLimit::Bytes(1 << 30), + time: RekeyTimeLimit::Seconds(7_200), + }) + ); // ForwardX11Timeout should be from *.example.com assert_eq!(host_config.forward_x11_timeout, Some("30m".to_string())); @@ -917,7 +939,13 @@ Host test RekeyLimit 1G 1h "#; let config = SshConfig::parse(config_content).unwrap(); - assert_eq!(config.hosts[0].rekey_limit, Some("1G 1h".to_string())); + assert_eq!( + config.hosts[0].rekey_limit, + Some(RekeyLimit { + data: RekeyDataLimit::Bytes(1 << 30), + time: RekeyTimeLimit::Seconds(3_600), + }) + ); // Test ForwardX11Timeout - should reject invalid format let config_content = r#" diff --git a/src/ssh/ssh_config/parser/options/connection.rs b/src/ssh/ssh_config/parser/options/connection.rs index 72c70572..5e11335d 100644 --- a/src/ssh/ssh_config/parser/options/connection.rs +++ b/src/ssh/ssh_config/parser/options/connection.rs @@ -17,9 +17,9 @@ //! Handles connection-related configuration options including keepalive //! settings, timeouts, compression, and network settings. -use crate::ssh::ssh_config::IpQosPolicy; use crate::ssh::ssh_config::parser::helpers::parse_yes_no; use crate::ssh::ssh_config::types::SshHostConfig; +use crate::ssh::ssh_config::{IpQosPolicy, RekeyLimit}; use anyhow::{Context, Result}; /// Parse connection-related SSH configuration options @@ -177,162 +177,20 @@ pub(super) fn parse_connection_option( if args.is_empty() { anyhow::bail!("RekeyLimit requires a value at line {line_number}"); } - // RekeyLimit can have one or two values (data limit and time limit) - // Format: [