fix: 支持无 Shell 的 SFTP-only 连接

This commit is contained in:
victorwon2001 committed 2026-09-07 11:45:32 +09:00
1 parent 944b82d99e
commit bd7e8c4a9c
3 files changed
+1340 -88

No files matched your search

+4
View File
@@ -46,6 +46,10 @@ pub use util::{
RemoteTextFile, TextFileOpenResult, WriteRemoteTextResult, classify_text_file,
};
pub(crate) async fn probe_sftp_subsystem(ssh_handle: &Arc<SshConnectionHandles>) -> AppResult<()> {
SftpBackend::probe(ssh_handle).await
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CopyEndpointKind {
+600 -15
View File
@@ -286,6 +286,58 @@ async fn install_remote_shell_integration<H: client::Handler>(
.map(|_| ())
}
const CHANNEL_REQUEST_REPLY_TIMEOUT_MS: u64 = 10_000;
async fn wait_channel_request_reply(
channel: &mut russh::Channel<client::Msg>,
request_name: &str,
) -> AppResult<()> {
let reply = timeout(
Duration::from_millis(CHANNEL_REQUEST_REPLY_TIMEOUT_MS),
async {
loop {
match channel.wait().await {
Some(ChannelMsg::Success) => return Ok(()),
Some(ChannelMsg::Failure) => {
return Err(AppError::Channel(format!(
"{request_name} request rejected by server"
)));
}
Some(ChannelMsg::Close | ChannelMsg::Eof) | None => {
return Err(AppError::Channel(format!(
"SSH channel closed before {request_name} request completed"
)));
}
Some(_) => {}
}
}
},
)
.await;
match reply {
Ok(result) => result,
Err(_) => Err(AppError::Channel(format!(
"{request_name} request timed out"
))),
}
}
async fn close_failed_interactive_channel(
channel: &russh::Channel<client::Msg>,
session_id: &str,
stage: &str,
) {
if let Err(error) = channel.close().await {
tracing::debug!(
session_id = %session_id,
stage,
%error,
"Failed to close rejected SSH interactive channel"
);
}
}
/// Opens a PTY shell channel and detects the remote shell type.
///
/// Returns `(channel, Option<injection_script>, ready_marker)`.
@@ -317,7 +369,7 @@ pub(super) async fn open_shell_channel<H: client::Handler>(
session_id = %session_id,
"SSH interactive channel opening"
);
let channel = match handle.channel_open_session().await {
let mut channel = match handle.channel_open_session().await {
Ok(channel) => {
tracing::info!(
session_id = %session_id,
@@ -349,16 +401,28 @@ pub(super) async fn open_shell_channel<H: client::Handler>(
}
}
if let Some(fake_cookie_hex) = x11_fake_cookie_hex {
if let Err(error) = channel
match channel
.request_x11(true, false, "MIT-MAGIC-COOKIE-1", fake_cookie_hex, 0)
.await
{
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable X11 forwarding"
);
local_notice = Some(super::x11_forwarding::enable_failed_message());
Ok(()) => {
if let Err(error) = wait_channel_request_reply(&mut channel, "X11").await {
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable X11 forwarding"
);
local_notice = Some(super::x11_forwarding::enable_failed_message());
}
}
Err(error) => {
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable X11 forwarding"
);
local_notice = Some(super::x11_forwarding::enable_failed_message());
}
}
}
@@ -373,7 +437,7 @@ pub(super) async fn open_shell_channel<H: client::Handler>(
"SSH PTY request sending"
);
if let Err(error) = channel
.request_pty(false, terminal_type, 80, 24, 0, 0, &[])
.request_pty(true, terminal_type, 80, 24, 0, 0, &[])
.await
{
tracing::warn!(
@@ -381,8 +445,18 @@ pub(super) async fn open_shell_channel<H: client::Handler>(
%error,
"SSH PTY request failed"
);
close_failed_interactive_channel(&channel, session_id, "pty-send").await;
return Err(AppError::Channel(format!("PTY request failed: {}", error)));
}
if let Err(error) = wait_channel_request_reply(&mut channel, "PTY").await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH PTY request rejected"
);
close_failed_interactive_channel(&channel, session_id, "pty-reply").await;
return Err(error);
}
tracing::info!(
session_id = %session_id,
"SSH PTY request completed"
@@ -395,17 +469,27 @@ pub(super) async fn open_shell_channel<H: client::Handler>(
session_id = %session_id,
"SSH shell request sending"
);
if let Err(error) = channel.request_shell(false).await {
if let Err(error) = channel.request_shell(true).await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH shell request failed"
);
close_failed_interactive_channel(&channel, session_id, "shell-send").await;
return Err(AppError::Channel(format!(
"Shell request failed: {}",
error
)));
}
if let Err(error) = wait_channel_request_reply(&mut channel, "Shell").await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH shell request rejected"
);
close_failed_interactive_channel(&channel, session_id, "shell-reply").await;
return Err(error);
}
tracing::info!(
session_id = %session_id,
"SSH shell request completed"
@@ -1568,6 +1652,86 @@ pub(super) async fn ssh_io_loop(
let _ = app.emit(&closed_event, ());
}
async fn run_sftp_only_session_commands(
session_id: &str,
manager: &Arc<SessionManager>,
mut cmd_rx: SessionCommandReceiver,
mut disconnect_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> &'static str {
let close_reason = loop {
tokio::select! {
disconnect = disconnect_rx.recv() => {
match disconnect {
Some(reason) => {
tracing::info!(
session_id = %session_id,
%reason,
"SFTP-only SSH transport disconnected"
);
break "remote-transport-disconnect";
}
None => break "remote-transport-ended",
}
}
command = cmd_rx.recv() => match command {
Some(SessionCommand::AttachConfirmed { ack }) => {
let _ = ack.send(());
}
Some(SessionCommand::CaptureExec { result_tx, .. }) => {
drop(result_tx);
}
Some(SessionCommand::Close) => break "local-close-request",
Some(
SessionCommand::DetachRenderer
| SessionCommand::Write { .. }
| SessionCommand::PauseOutput
| SessionCommand::ResumeOutput
| SessionCommand::AckOutput { .. }
| SessionCommand::Resize { .. }
| SessionCommand::CancelCapture { .. }
| SessionCommand::ZmodemAcceptDownload { .. }
| SessionCommand::ZmodemAcceptUpload { .. }
| SessionCommand::ZmodemCancel,
) => {}
None => break "session-command-channel-closed",
}
}
};
manager.remove_session(session_id).await;
close_reason
}
pub(super) async fn sftp_only_ssh_lifecycle_loop(
app: AppHandle,
session_id: String,
manager: Arc<SessionManager>,
_handle: SshHandle,
cmd_rx: SessionCommandReceiver,
disconnect_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
connection_id: Option<String>,
) {
tracing::info!(session_id = %session_id, "SFTP-only SSH lifecycle loop started");
let close_reason =
run_sftp_only_session_commands(&session_id, &manager, cmd_rx, disconnect_rx).await;
if let Some(ref conn_id) = connection_id {
if let Some(tunnel_mgr) = app.try_state::<Arc<super::TunnelManager>>() {
tunnel_mgr
.close_auto_tunnels_for_connection(&app, conn_id)
.await;
}
}
tracing::info!(
session_id = %session_id,
close_reason,
"SFTP-only SSH session closed"
);
let closed_event = format!("session-closed-{session_id}");
let _ = app.emit(&closed_event, ());
}
async fn handle_zmodem_actions(
app: &AppHandle,
event_name: &str,
@@ -1658,15 +1822,20 @@ mod tests {
append_suppressed_visible_and_take_passthrough, build_post_login_command_input,
build_startup_command_input, discard_suppressed_output, fallback_shell_integration_timeout,
handle_injection_result, handle_injection_timeout, injection_has_timed_out_at,
on_initial_injection_sent, open_shell_channel, should_send_initial_injection,
on_initial_injection_sent, open_shell_channel, run_sftp_only_session_commands,
should_send_initial_injection,
};
use crate::config::SftpCwdFollowMode;
use crate::config::{AiExecutionProfile, SftpCwdFollowMode, SshProfile};
use crate::core::ssh::osc::{OscResult, OscStripper, build_ready_marker};
use crate::core::{
DynamicTitleCapabilities, SessionCommand, SessionHandle, SessionInfo, SessionManager,
SessionType, session_command_channel,
};
use russh::{Channel, ChannelId, Disconnect, client, server};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Duration, Sleep, timeout};
struct TestClient;
@@ -1682,8 +1851,18 @@ mod tests {
}
}
fn assert_fake_server_session(result: Result<(), russh::Error>) {
match result {
Ok(()) => {}
Err(russh::Error::IO(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => panic!("fake SSH server session: {error:?}"),
}
}
struct TestServer {
agent_request_tx: mpsc::UnboundedSender<()>,
reject_shell: bool,
accept_x11: bool,
}
impl server::Handler for TestServer {
@@ -1703,6 +1882,51 @@ mod tests {
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut server::Session,
) -> Result<(), Self::Error> {
let _ = session.channel_success(channel);
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.reject_shell {
let _ = session.channel_failure(channel);
} else {
let _ = session.channel_success(channel);
}
Ok(())
}
async fn x11_request(
&mut self,
channel: ChannelId,
_single_connection: bool,
_x11_auth_protocol: &str,
_x11_auth_cookie: &str,
_x11_screen_number: u32,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.accept_x11 {
let _ = session.channel_success(channel);
} else {
let _ = session.channel_failure(channel);
}
Ok(())
}
async fn agent_request(
&mut self,
_channel: ChannelId,
@@ -1730,11 +1954,15 @@ mod tests {
let session = server::run_stream(
server_config,
server_stream,
TestServer { agent_request_tx },
TestServer {
agent_request_tx,
reject_shell: false,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
session.await.expect("fake SSH server session");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
@@ -1792,12 +2020,369 @@ mod tests {
.expect("fake SSH server task");
}
#[tokio::test]
async fn interactive_shell_rejection_is_reported() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: true,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let result = open_shell_channel(
&mut handle,
"shell-rejection-test",
None,
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await;
let error = match result {
Ok(_) => panic!("rejected shell request must fail interactive setup"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("Shell request rejected by server")
);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn interactive_shell_requests_agent_forwarding_only_when_enabled() {
assert_agent_forwarding_request(false).await;
assert_agent_forwarding_request(true).await;
}
#[tokio::test]
async fn x11_success_does_not_hide_shell_rejection() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: true,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let error = open_shell_channel(
&mut handle,
"x11-shell-rejection-test",
Some("00112233445566778899aabbccddeeff"),
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect_err("Shell rejection must not be consumed as an earlier X11 reply");
assert!(
error
.to_string()
.contains("Shell request rejected by server")
);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn x11_rejection_is_non_fatal_when_pty_and_shell_succeed() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: false,
accept_x11: false,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let (channel, _, _, _, notice) = open_shell_channel(
&mut handle,
"x11-rejection-test",
Some("00112233445566778899aabbccddeeff"),
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect("X11 rejection must not fail an otherwise valid interactive shell");
assert!(notice.is_some());
drop(channel);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn sftp_only_session_close_cleans_up() {
let session_id = "sftp-only-session".to_string();
let manager = Arc::new(SessionManager::new());
let (cmd_tx, cmd_rx) = session_command_channel(session_id.clone());
let info = SessionInfo {
id: session_id.clone(),
name: "sftp-only".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: AiExecutionProfile::Disabled,
injection_active: false,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: true,
remote_stats_enabled: false,
ssh_profile: Some(SshProfile::Standard),
};
manager
.add_session(SessionHandle {
info,
cmd_tx,
startup_input_barrier: None,
ssh_config: None,
ssh_handle: None,
cwd: Arc::new(tokio::sync::Mutex::new(Default::default())),
remote_fs: None,
})
.await;
let loop_manager = manager.clone();
let loop_session_id = session_id.clone();
let (_disconnect_tx, disconnect_rx) = mpsc::unbounded_channel();
let loop_task = tokio::spawn(async move {
run_sftp_only_session_commands(&loop_session_id, &loop_manager, cmd_rx, disconnect_rx)
.await
});
let (attach_tx, attach_rx) = oneshot::channel();
manager
.send_command(
&session_id,
SessionCommand::AttachConfirmed { ack: attach_tx },
)
.await
.expect("attach command");
timeout(Duration::from_secs(1), attach_rx)
.await
.expect("attach acknowledgement must not block")
.expect("attach acknowledgement sender");
let (capture_tx, capture_rx) = oneshot::channel();
manager
.send_command(
&session_id,
SessionCommand::CaptureExec {
marker_id: "capture".to_string(),
wrapped_command: b"echo blocked".to_vec(),
result_tx: capture_tx,
},
)
.await
.expect("capture command");
let capture_result = timeout(Duration::from_secs(1), capture_rx)
.await
.expect("capture result sender must be dropped immediately");
assert!(capture_result.is_err());
manager
.send_command(&session_id, SessionCommand::Close)
.await
.expect("close command");
let close_reason = timeout(Duration::from_secs(1), loop_task)
.await
.expect("SFTP-only loop must close promptly")
.expect("SFTP-only loop task");
assert_eq!(close_reason, "local-close-request");
assert!(manager.list_sessions().await.is_empty());
}
#[tokio::test]
async fn sftp_only_session_remote_disconnect_cleans_up() {
let session_id = "sftp-only-remote-disconnect".to_string();
let manager = Arc::new(SessionManager::new());
let (cmd_tx, cmd_rx) = session_command_channel(session_id.clone());
let info = SessionInfo {
id: session_id.clone(),
name: "sftp-only".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: AiExecutionProfile::Disabled,
injection_active: false,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: true,
remote_stats_enabled: false,
ssh_profile: Some(SshProfile::Standard),
};
manager
.add_session(SessionHandle {
info,
cmd_tx,
startup_input_barrier: None,
ssh_config: None,
ssh_handle: None,
cwd: Arc::new(tokio::sync::Mutex::new(Default::default())),
remote_fs: None,
})
.await;
let (disconnect_tx, disconnect_rx) = mpsc::unbounded_channel();
let loop_manager = manager.clone();
let loop_session_id = session_id.clone();
let loop_task = tokio::spawn(async move {
run_sftp_only_session_commands(&loop_session_id, &loop_manager, cmd_rx, disconnect_rx)
.await
});
disconnect_tx
.send("SSH server disconnected: test".to_string())
.expect("disconnect notification");
let close_reason = timeout(Duration::from_secs(1), loop_task)
.await
.expect("SFTP-only loop must react to remote disconnect")
.expect("SFTP-only loop task");
assert_eq!(close_reason, "remote-transport-disconnect");
assert!(manager.list_sessions().await.is_empty());
}
#[test]
fn post_login_input_normalizes_line_endings_and_adds_enter() {
let input = build_post_login_command_input("cd /opt/app\nclear").expect("input");
+736 -73
View File
@@ -5,7 +5,7 @@ use super::client::{
SshHandle, SshHandler, SshRawHandle, SshStartupCommand, build_client_config,
connect_via_stream, connect_with_proxy,
};
use super::io::{open_shell_channel, ssh_io_loop};
use super::io::{open_shell_channel, sftp_only_ssh_lifecycle_loop, ssh_io_loop};
use crate::config::{
AiExecutionProfile, SshAgentForwardingConfig, SshAgentForwardingPolicy, SshProfile,
SshRuntimeMode, effective_cwd_follow_mode_for_runtime,
@@ -43,6 +43,47 @@ async fn create_authenticated_connection(
.await
}
async fn create_session_connection_with_disconnect(
app: &AppHandle,
config: &SshConfig,
enable_agent_forwarding: bool,
diagnostics: Option<SshDiagnosticContext>,
) -> AppResult<(
SshHandle,
Option<mpsc::UnboundedReceiver<super::x11_forwarding::X11ChannelOpen>>,
mpsc::UnboundedReceiver<String>,
)> {
loop {
let (disconnect_tx, disconnect_rx) = mpsc::unbounded_channel();
match create_authenticated_connection_with_notifications(
app,
config,
Some(disconnect_tx),
None,
enable_agent_forwarding,
diagnostics.clone(),
)
.await
{
Err(error) if is_agent_auth_retry(&error) => continue,
Ok((handle, x11_rx)) => return Ok((handle, x11_rx, disconnect_rx)),
Err(error) => return Err(error),
}
}
}
async fn create_sftp_only_replacement_connection(
app: &AppHandle,
config: &SshConfig,
diagnostics: &SshDiagnosticContext,
) -> AppResult<(SshHandle, mpsc::UnboundedReceiver<String>)> {
let (connection, _x11_rx, disconnect_rx) =
create_session_connection_with_disconnect(app, config, false, Some(diagnostics.clone()))
.await?;
crate::core::sftp::probe_sftp_subsystem(&connection).await?;
Ok((connection, disconnect_rx))
}
async fn create_authenticated_connection_with_notifications(
app: &AppHandle,
config: &SshConfig,
@@ -360,6 +401,48 @@ fn resolve_runtime_capabilities(config: &SshConfig) -> SshRuntimeCapabilities {
}
}
async fn try_sftp_only_fallback<F, Fut>(
session_id: &str,
remote_file_browser_enabled: bool,
shell_error: AppError,
probe: F,
) -> AppResult<()>
where
F: FnOnce() -> Fut,
Fut: Future<Output = AppResult<()>>,
{
if !remote_file_browser_enabled {
return Err(shell_error);
}
match probe().await {
Ok(()) => {
tracing::info!(
session_id = %session_id,
shell_error = %shell_error,
"SSH shell unavailable; continuing with SFTP-only session"
);
Ok(())
}
Err(probe_error) => {
tracing::warn!(
session_id = %session_id,
shell_error = %shell_error,
sftp_probe_error = %probe_error,
"SFTP probe failed on current SSH transport"
);
Err(shell_error)
}
}
}
fn apply_sftp_only_runtime_policy(info: &mut SessionInfo) {
info.ai_execution_profile = AiExecutionProfile::Disabled;
info.injection_active = false;
info.remote_file_browser_enabled = true;
info.remote_stats_enabled = false;
}
/// Creates an authenticated SSH handle for a saved connection without opening a PTY/shell.
/// Used by tunnels to establish their own independent SSH connections.
#[allow(dead_code)]
@@ -469,21 +552,9 @@ async fn create_ssh_session_inner(
} else {
None
};
let (ssh_connection, x11_rx) = loop {
match create_authenticated_connection_with_notifications(
&app,
&config,
None,
None,
true,
Some(diagnostics.clone()),
)
.await
{
Err(error) if is_agent_auth_retry(&error) => continue,
result => break result,
}
}?;
let (mut ssh_connection, x11_rx, mut disconnect_rx) =
create_session_connection_with_disconnect(&app, &config, true, Some(diagnostics.clone()))
.await?;
diagnostics.set_stage(SshDiagnosticStage::Authenticated);
let capabilities = resolve_runtime_capabilities(&config);
let effective_cwd_follow_mode = effective_cwd_follow_mode_for_runtime(
@@ -511,28 +582,82 @@ async fn create_ssh_session_inner(
let forwarding_enabled =
should_attach_agent_forwarding(true, effective_forwarding_config(&config).enabled);
let (channel, injection_script, ready_marker, detected_shell, initial_notice) =
open_shell_channel(
&mut handle,
&session_id,
x11_config.as_ref().map(|cfg| cfg.fake_cookie_hex.as_str()),
forwarding_enabled,
config.terminal_type.as_str(),
capabilities.remote_file_browser_enabled,
capabilities.network_device_profile,
effective_cwd_follow_mode,
config.sftp.shell_detection_timeout_ms,
Some(diagnostics.clone()),
)
.await?;
let shell_result = open_shell_channel(
&mut handle,
&session_id,
x11_config.as_ref().map(|cfg| cfg.fake_cookie_hex.as_str()),
forwarding_enabled,
config.terminal_type.as_str(),
capabilities.remote_file_browser_enabled,
capabilities.network_device_profile,
effective_cwd_follow_mode,
config.sftp.shell_detection_timeout_ms,
Some(diagnostics.clone()),
)
.await;
drop(handle);
let injection_active = injection_script.is_some();
let mut sftp_only = false;
let shell = match shell_result {
Ok(shell) => Some(shell),
Err(shell_error) => {
let same_transport_probe = try_sftp_only_fallback(
&session_id,
capabilities.remote_file_browser_enabled,
shell_error,
|| crate::core::sftp::probe_sftp_subsystem(&ssh_connection),
)
.await;
match same_transport_probe {
Ok(()) => {
sftp_only = true;
None
}
Err(shell_error) => {
if !capabilities.remote_file_browser_enabled {
return Err(shell_error);
}
match create_sftp_only_replacement_connection(&app, &config, &diagnostics).await
{
Ok((replacement, replacement_disconnect_rx)) => {
ssh_connection = replacement;
disconnect_rx = replacement_disconnect_rx;
sftp_only = true;
tracing::info!(
session_id = %session_id,
"SFTP-only fallback recovered on a fresh authenticated transport"
);
None
}
Err(reconnect_error) => {
tracing::warn!(
session_id = %session_id,
%reconnect_error,
"Fresh SSH transport could not establish SFTP-only fallback"
);
return Err(shell_error);
}
}
}
}
}
};
debug_assert_eq!(sftp_only, shell.is_none());
let injection_active = shell
.as_ref()
.is_some_and(|(_, injection_script, _, _, _)| injection_script.is_some());
if let (Some(rx), Some(x11_config)) = (x11_rx, x11_config) {
super::x11_forwarding::spawn_x11_forwarder(app.clone(), session_id.clone(), rx, x11_config);
if !sftp_only {
if let (Some(rx), Some(x11_config)) = (x11_rx, x11_config) {
super::x11_forwarding::spawn_x11_forwarder(
app.clone(),
session_id.clone(),
rx,
x11_config,
);
}
}
let session_info = SessionInfo {
let mut session_info = SessionInfo {
id: session_id.clone(),
name: config.name.clone(),
session_type: SessionType::SSH,
@@ -547,15 +672,16 @@ async fn create_ssh_session_inner(
remote_stats_enabled: capabilities.remote_stats_enabled,
ssh_profile: Some(config.ssh_profile.clone()),
};
if sftp_only {
apply_sftp_only_runtime_policy(&mut session_info);
}
let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(Default::default()));
let ssh_config_arc: Arc<dyn std::any::Any + Send + Sync> = Arc::new(config.clone());
let ssh_handle_arc: Arc<dyn std::any::Any + Send + Sync> = ssh_connection.clone();
let output_control_tx = cmd_tx.clone();
let session_handle = SessionHandle {
info: session_info.clone(),
cmd_tx,
cmd_tx: cmd_tx.clone(),
startup_input_barrier: None,
ssh_config: Some(ssh_config_arc),
ssh_handle: Some(ssh_handle_arc),
@@ -564,8 +690,10 @@ async fn create_ssh_session_inner(
};
manager.add_session(session_handle).await;
tracing::info!(session_id = %session_id, "SSH session registered");
if let Some(hook) = session_ready_hook.as_ref() {
hook(&session_info);
if !sftp_only {
if let Some(hook) = session_ready_hook.as_ref() {
hook(&session_info);
}
}
if let Some(ref conn_id) = connection_id {
@@ -582,36 +710,51 @@ async fn create_ssh_session_inner(
}
let io_session_id = session_id.clone();
let io_manager = manager.clone();
let io_handle = ssh_connection.clone();
let io_connection_id = connection_id.clone();
let post_login = config.post_login.clone();
let startup_command = startup_command.clone();
let backspace_mode = config.backspace_mode.clone();
let encoding = config.encoding.clone();
tokio::spawn(async move {
ssh_io_loop(
app,
io_session_id,
io_manager,
channel,
io_handle,
cmd_rx,
output_control_tx,
cwd,
io_connection_id,
injection_script,
ready_marker,
detected_shell,
post_login,
startup_command,
backspace_mode,
initial_notice,
encoding,
Some(diagnostics),
)
.await;
});
match shell {
Some((channel, injection_script, ready_marker, detected_shell, initial_notice)) => {
let output_control_tx = cmd_tx;
let post_login = config.post_login.clone();
let backspace_mode = config.backspace_mode.clone();
let encoding = config.encoding.clone();
tokio::spawn(async move {
ssh_io_loop(
app,
io_session_id,
manager,
channel,
ssh_connection,
cmd_rx,
output_control_tx,
cwd,
connection_id,
injection_script,
ready_marker,
detected_shell,
post_login,
startup_command,
backspace_mode,
initial_notice,
encoding,
Some(diagnostics),
)
.await;
});
}
None => {
tokio::spawn(async move {
sftp_only_ssh_lifecycle_loop(
app,
io_session_id,
manager,
ssh_connection,
cmd_rx,
disconnect_rx,
connection_id,
)
.await;
});
}
}
Ok(session_id)
}
@@ -807,15 +950,27 @@ pub async fn create_multiplexed_ssh_session(
#[cfg(test)]
mod tests {
use super::{
is_agent_auth_retry, is_raw_agent_forwarding_config, resolve_runtime_capabilities,
should_attach_agent_forwarding,
apply_sftp_only_runtime_policy, is_agent_auth_retry, is_raw_agent_forwarding_config,
resolve_runtime_capabilities, should_attach_agent_forwarding, try_sftp_only_fallback,
};
use crate::config::{
SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy,
SshAgentForwardingSources, SshProfile, SshRuntimeMode, SshTerminalType,
SftpCwdFollowMode, SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig,
SshAgentForwardingPolicy, SshAgentForwardingSources, SshProfile, SshRuntimeMode,
SshTerminalType,
};
use crate::core::ssh::client::{SshAuth, SshConfig};
use crate::core::{
DynamicTitleCapabilities, SessionHandle, SessionInfo, SessionManager, SessionType,
session_command_channel,
};
use crate::error::AppError;
use russh::{Channel, ChannelId, ChannelMsg, ChannelOpenFailure, Disconnect, client, server};
use russh_sftp::protocol::{File, FileAttributes, Handle, Name, Status, StatusCode, Version};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Mutex;
use tokio::time::{Duration, timeout};
#[test]
fn agent_forwarding_requires_both_global_and_connection_flags() {
@@ -892,6 +1047,333 @@ mod tests {
}
}
struct SftpOnlyTestClient;
impl client::Handler for SftpOnlyTestClient {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
#[derive(Clone, Copy)]
enum TestShellBehavior {
Reject,
AcceptWithoutExec,
Disconnect,
}
fn assert_fake_server_session(result: Result<(), russh::Error>) {
match result {
Ok(()) => {}
Err(russh::Error::IO(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => panic!("fake SSH server session: {error:?}"),
}
}
struct SftpOnlyTestServer {
channels: Arc<Mutex<HashMap<ChannelId, Channel<server::Msg>>>>,
allow_sftp: bool,
subsystem_requests: Arc<AtomicUsize>,
shell_behavior: TestShellBehavior,
single_session_channel: bool,
}
impl server::Handler for SftpOnlyTestServer {
type Error = russh::Error;
async fn auth_none(&mut self, _user: &str) -> Result<server::Auth, Self::Error> {
Ok(server::Auth::Accept)
}
async fn channel_open_session(
&mut self,
channel: Channel<server::Msg>,
reply: server::ChannelOpenHandle,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.single_session_channel && !self.channels.lock().await.is_empty() {
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
self.channels.lock().await.insert(channel.id(), channel);
reply.accept().await;
Ok(())
}
async fn channel_close(
&mut self,
channel: ChannelId,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
self.channels.lock().await.remove(&channel);
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut server::Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?;
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
match self.shell_behavior {
TestShellBehavior::Reject => session.channel_failure(channel)?,
TestShellBehavior::AcceptWithoutExec => session.channel_success(channel)?,
TestShellBehavior::Disconnect => {
session.disconnect(Disconnect::ByApplication, "SFTP-only account", "")?;
}
}
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
_data: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
session.channel_failure(channel)?;
Ok(())
}
async fn subsystem_request(
&mut self,
channel_id: ChannelId,
name: &str,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if name != "sftp" {
session.channel_failure(channel_id)?;
return Ok(());
}
self.subsystem_requests.fetch_add(1, Ordering::SeqCst);
if !self.allow_sftp {
session.channel_failure(channel_id)?;
return Ok(());
}
let Some(channel) = self.channels.lock().await.remove(&channel_id) else {
session.channel_failure(channel_id)?;
return Ok(());
};
session.channel_success(channel_id)?;
russh_sftp::server::run(channel.into_stream(), TestSftpSession::default()).await;
Ok(())
}
}
#[derive(Default)]
struct TestSftpSession {
root_dir_read_done: bool,
}
impl russh_sftp::server::Handler for TestSftpSession {
type Error = StatusCode;
fn unimplemented(&self) -> Self::Error {
StatusCode::OpUnsupported
}
async fn init(
&mut self,
_version: u32,
_extensions: HashMap<String, String>,
) -> Result<Version, Self::Error> {
Ok(Version::new())
}
async fn close(&mut self, id: u32, _handle: String) -> Result<Status, Self::Error> {
Ok(Status {
id,
status_code: StatusCode::Ok,
error_message: "Ok".to_string(),
language_tag: "en-US".to_string(),
})
}
async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
self.root_dir_read_done = false;
Ok(Handle { id, handle: path })
}
async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
if handle == "/" && !self.root_dir_read_done {
self.root_dir_read_done = true;
return Ok(Name {
id,
files: vec![File::new("upload.txt", FileAttributes::default())],
});
}
Err(StatusCode::Eof)
}
async fn realpath(&mut self, id: u32, _path: String) -> Result<Name, Self::Error> {
Ok(Name {
id,
files: vec![File::dummy("/home/sftp")],
})
}
}
async fn start_sftp_only_test_connection(
allow_sftp: bool,
shell_behavior: TestShellBehavior,
single_session_channel: bool,
) -> (
client::Handle<SftpOnlyTestClient>,
tokio::task::JoinHandle<()>,
Arc<AtomicUsize>,
) {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let channels = Arc::new(Mutex::new(HashMap::new()));
let subsystem_requests = Arc::new(AtomicUsize::new(0));
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_channels = channels.clone();
let server_subsystem_requests = subsystem_requests.clone();
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
SftpOnlyTestServer {
channels: server_channels,
allow_sftp,
subsystem_requests: server_subsystem_requests,
shell_behavior,
single_session_channel,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
SftpOnlyTestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
(handle, server_task, subsystem_requests)
}
async fn open_rejected_shell(handle: &mut client::Handle<SftpOnlyTestClient>) -> AppError {
super::open_shell_channel(
handle,
"sftp-only-integration-test",
None,
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect_err("test server must reject the interactive shell")
}
async fn probe_real_sftp(
handle: &client::Handle<SftpOnlyTestClient>,
verify_file_browser_operations: bool,
) -> Result<(), AppError> {
let mut channel = handle
.channel_open_session()
.await
.map_err(|error| AppError::Channel(format!("Failed to open SFTP channel: {error}")))?;
channel
.request_subsystem(true, "sftp")
.await
.map_err(|error| {
AppError::Channel(format!("Failed to request SFTP subsystem: {error}"))
})?;
match timeout(Duration::from_secs(1), channel.wait()).await {
Ok(Some(ChannelMsg::Success)) => {}
Ok(Some(ChannelMsg::Failure)) => {
return Err(AppError::Channel(
"SFTP subsystem request rejected by server".to_string(),
));
}
Ok(Some(_)) => {
return Err(AppError::Channel(
"Unexpected SSH response to SFTP subsystem request".to_string(),
));
}
Ok(None) => {
return Err(AppError::Channel(
"SSH channel closed during SFTP subsystem request".to_string(),
));
}
Err(_) => {
return Err(AppError::Channel(
"SFTP subsystem request timed out".to_string(),
));
}
}
let sftp = russh_sftp::client::SftpSession::new(channel.into_stream()).await?;
if verify_file_browser_operations {
assert_eq!(sftp.canonicalize(".").await?, "/home/sftp");
let entries: Vec<_> = sftp.read_dir("/").await?.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name(), "upload.txt");
}
sftp.close().await?;
Ok(())
}
async fn stop_sftp_only_test_connection(
handle: &client::Handle<SftpOnlyTestClient>,
server_task: tokio::task::JoinHandle<()>,
) {
let _ = handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await;
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[test]
fn network_device_runtime_capabilities_disable_linux_only_features() {
let config = test_config(SshProfile::NetworkDevice);
@@ -927,6 +1409,187 @@ mod tests {
assert!(!capabilities.network_device_profile);
}
#[tokio::test]
async fn sftp_only_fallback_accepts_sftp_when_shell_is_rejected() {
let (mut handle, server_task, subsystem_requests) =
start_sftp_only_test_connection(true, TestShellBehavior::Reject, true).await;
let shell_error = open_rejected_shell(&mut handle).await;
let result = try_sftp_only_fallback("test-session", true, shell_error, || {
probe_real_sftp(&handle, true)
})
.await;
assert!(result.is_ok());
assert_eq!(subsystem_requests.load(Ordering::SeqCst), 1);
let manager = SessionManager::new();
let (cmd_tx, _cmd_rx) = session_command_channel("test-session".to_string());
let mut info = SessionInfo {
id: "test-session".to_string(),
name: "sftp-only".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: crate::config::AiExecutionProfile::Posix,
injection_active: true,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: false,
remote_stats_enabled: true,
ssh_profile: Some(SshProfile::Standard),
};
apply_sftp_only_runtime_policy(&mut info);
manager
.add_session(SessionHandle {
info,
cmd_tx,
startup_input_barrier: None,
ssh_config: None,
ssh_handle: None,
cwd: Arc::new(Mutex::new(Default::default())),
remote_fs: None,
})
.await;
let registered = manager.list_sessions().await;
assert_eq!(registered.len(), 1);
assert_eq!(registered[0].session_type, SessionType::SSH);
assert!(registered[0].connected);
assert!(registered[0].remote_file_browser_enabled);
assert!(!registered[0].remote_stats_enabled);
assert_eq!(
registered[0].ai_execution_profile,
crate::config::AiExecutionProfile::Disabled
);
stop_sftp_only_test_connection(&handle, server_task).await;
}
#[tokio::test]
async fn interactive_shell_is_preserved_when_shell_detection_exec_is_rejected() {
let (mut shell_handle, shell_server_task, subsystem_requests) =
start_sftp_only_test_connection(true, TestShellBehavior::AcceptWithoutExec, false)
.await;
let (channel, injection_script, _, detected_shell, _) = super::open_shell_channel(
&mut shell_handle,
"interactive-shell-detection-rejection-test",
None,
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::ShellIntegration,
50,
None,
)
.await
.expect("server accepts the shell request");
assert!(injection_script.is_none());
assert!(detected_shell.is_none());
probe_real_sftp(&shell_handle, true)
.await
.expect("SFTP must remain available alongside the accepted interactive shell");
assert_eq!(subsystem_requests.load(Ordering::SeqCst), 1);
let _ = channel.close().await;
stop_sftp_only_test_connection(&shell_handle, shell_server_task).await;
}
#[tokio::test]
async fn sftp_only_fallback_recovers_after_shell_attempt_disconnects_transport() {
let (mut broken_handle, broken_server_task, _subsystem_requests) =
start_sftp_only_test_connection(true, TestShellBehavior::Disconnect, false).await;
let shell_error = open_rejected_shell(&mut broken_handle).await;
assert!(
shell_error.to_string().contains("Shell")
|| shell_error.to_string().contains("channel")
|| shell_error.to_string().contains("SSH"),
"disconnecting shell attempt must fail interactive setup"
);
assert!(
probe_real_sftp(&broken_handle, false).await.is_err(),
"disconnected transport must not be reusable for SFTP"
);
let (fresh_handle, fresh_server_task, fresh_subsystem_requests) =
start_sftp_only_test_connection(true, TestShellBehavior::Reject, false).await;
probe_real_sftp(&fresh_handle, true)
.await
.expect("fresh authenticated transport must recover SFTP-only access");
assert_eq!(fresh_subsystem_requests.load(Ordering::SeqCst), 1);
stop_sftp_only_test_connection(&fresh_handle, fresh_server_task).await;
stop_sftp_only_test_connection(&broken_handle, broken_server_task).await;
}
#[tokio::test]
async fn sftp_only_fallback_preserves_shell_error_when_sftp_probe_fails() {
let (mut handle, server_task, subsystem_requests) =
start_sftp_only_test_connection(false, TestShellBehavior::Reject, false).await;
let shell_error = open_rejected_shell(&mut handle).await;
let shell_error_text = shell_error.to_string();
let error = try_sftp_only_fallback("test-session", true, shell_error, || {
probe_real_sftp(&handle, false)
})
.await
.expect_err("SFTP probe failure must preserve the shell error");
assert_eq!(error.to_string(), shell_error_text);
assert_eq!(subsystem_requests.load(Ordering::SeqCst), 1);
stop_sftp_only_test_connection(&handle, server_task).await;
}
#[tokio::test]
async fn sftp_only_fallback_skips_probe_when_file_browser_is_disabled() {
let (mut handle, server_task, subsystem_requests) =
start_sftp_only_test_connection(true, TestShellBehavior::Reject, false).await;
let shell_error = open_rejected_shell(&mut handle).await;
let shell_error_text = shell_error.to_string();
let error = try_sftp_only_fallback("test-session", false, shell_error, || {
probe_real_sftp(&handle, false)
})
.await
.expect_err("disabled file browser must not enable SFTP-only fallback");
assert_eq!(error.to_string(), shell_error_text);
assert_eq!(subsystem_requests.load(Ordering::SeqCst), 0);
stop_sftp_only_test_connection(&handle, server_task).await;
}
#[test]
fn sftp_only_runtime_policy_disables_shell_capabilities() {
let mut info = SessionInfo {
id: "test-session".to_string(),
name: "test".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: crate::config::AiExecutionProfile::Posix,
injection_active: true,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: false,
remote_stats_enabled: true,
ssh_profile: Some(SshProfile::Standard),
};
apply_sftp_only_runtime_policy(&mut info);
assert_eq!(info.session_type, SessionType::SSH);
assert!(info.connected);
assert!(info.remote_file_browser_enabled);
assert!(!info.remote_stats_enabled);
assert!(!info.injection_active);
assert_eq!(
info.ai_execution_profile,
crate::config::AiExecutionProfile::Disabled
);
}
#[test]
fn ssh_runtime_mode_defaults_to_standard() {
let config: SshConfig = serde_json::from_value(serde_json::json!({