Files
shell-rs/src/sftp/worker.rs
T
XuzandClaude Opus 5.5 c0bc970de8 SSH:密钥交换按 OpenSSH 的默认,连接失败写明原因
用户的堡垒机是老 Java 上的 Apache SSHD 2.10:没有 curve25519,DH 组交换最大
2048 位。russh 默认不带 NIST 曲线、组交换要至少 3072 位,于是选了组交换、服务
器找不到素数就断开;OpenSSH 选 ecdh-sha2-nistp256 能连上。现在照 OpenSSH 的
顺序加上 ecdh-sha2-nistp*,组交换下限 2048(OpenSSH 的),不加 SHA-1、CBC。
route_tests 用只给 2048 位组、只有 NIST 曲线的服务端守着。

终端和 SFTP 的连接失败改用 describe_login_error:沿错误链给出真正的原因,
没有共同算法时写明服务器支持哪些,找不到时给整条错误链,不再只剩「无法建立
SSH 连接,请检查地址、端口和主机密钥」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00

622 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
EntryKind, RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider,
TransferDetail, TransferDirection, TransferPhase, TransferProgress,
client::RemoteFs as _,
client::{SftpClient, is_network_error},
control::{Cancelled, TransferControl},
download::DownloadBatch,
journal::{DownloadJournal, Journal},
meter::TransferMeter,
model::{scp_local_target, scp_remote_target},
operations,
upload::UploadBatch,
};
use crate::{
host::HostLogin,
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts, describe_login_error},
};
use anyhow::{Result, anyhow};
use async_channel::{Receiver, Sender};
use std::{
path::PathBuf,
sync::{
Arc, Weak,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::{RwLock, watch};
/// How often the latency task looks for a connection to measure, and for
/// the one it is measuring to be dropped.
const LATENCY_POLL: Duration = Duration::from_millis(250);
pub struct SshSftpTransportProvider {
connector: SshConnector,
resume_dir: PathBuf,
download_resume_dir: PathBuf,
}
impl SshSftpTransportProvider {
/// Upload and download resume records live in separate directories.
pub fn new(connector: SshConnector, resume_dir: PathBuf, download_resume_dir: PathBuf) -> Self {
Self {
connector,
resume_dir,
download_resume_dir,
}
}
}
impl SftpTransportProvider for SshSftpTransportProvider {
fn create(&self, login: &HostLogin) -> Box<dyn SftpTransport> {
Box::new(SshSftpTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(login),
journal: Journal::new(self.resume_dir.clone()),
download_journal: DownloadJournal::new(self.download_resume_dir.clone()),
})
}
}
struct SshSftpTransport {
connector: SshConnector,
config: SshConnectionConfig,
journal: Journal,
download_journal: DownloadJournal,
}
/// The host's one transfer batch, whichever way it goes.
enum TransferBatch {
Upload(UploadBatch),
Download(DownloadBatch),
}
impl TransferBatch {
fn verify_host(&self, fingerprint: &str) -> Result<()> {
match self {
Self::Upload(batch) => batch.verify_host(fingerprint),
Self::Download(batch) => batch.verify_host(fingerprint),
}
}
fn is_complete(&self) -> bool {
match self {
Self::Upload(batch) => batch.is_complete(),
Self::Download(batch) => batch.is_complete(),
}
}
fn meter(&mut self) -> &mut TransferMeter {
match self {
Self::Upload(batch) => &mut batch.meter,
Self::Download(batch) => &mut batch.meter,
}
}
fn phase(&mut self, phase: TransferPhase, control: &TransferControl) {
self.meter().phase(phase, control);
}
/// Say what a reconnecting batch is waiting for; the next phase
/// other than reconnecting clears it.
fn set_note(&mut self, note: String) {
self.meter().progress.note = Some(note);
}
async fn run(&mut self, client: &SftpClient, control: &TransferControl) -> Result<()> {
match self {
Self::Upload(batch) => batch.run(client, control).await,
Self::Download(batch) => batch.run(client, control).await,
}
}
async fn discard(&self, client: &SftpClient) -> Result<()> {
match self {
Self::Upload(batch) => batch.discard(client).await,
Self::Download(batch) => batch.discard().await,
}
}
}
impl SftpTransport for SshSftpTransport {
fn run(
self: Box<Self>,
commands: Receiver<SftpCommand>,
events: Sender<SftpEvent>,
) -> Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(self.run_async(commands, events))
}
}
impl SshSftpTransport {
async fn connect(
&self,
prompts: Arc<SshPrompts>,
events: &Sender<SftpEvent>,
shutdown: &watch::Receiver<bool>,
shared: &RwLock<Option<Arc<SftpClient>>>,
) -> Result<Arc<SftpClient>> {
events.send(SftpEvent::Connecting).await?;
let mut stop = shutdown.clone();
let client = tokio::select! {
result = SftpClient::connect(&self.connector, &self.config, prompts) => Arc::new(result?),
_ = stop.changed() => return Err(Cancelled.into()),
};
let home = client.canonicalize(&RemotePath::new(".")?).await?;
*shared.write().await = Some(client.clone());
events.send(SftpEvent::Connected { home }).await?;
Ok(client)
}
async fn run_async(
&self,
commands: Receiver<SftpCommand>,
events: Sender<SftpEvent>,
) -> Result<()> {
let (shutdown_tx, shutdown) = watch::channel(false);
let (cancel_tx, cancel) = watch::channel(false);
let (answers_tx, answers) = async_channel::unbounded();
let (operations_tx, operations) = async_channel::bounded(1);
let prompt_events = events.clone();
let prompts = Arc::new(SshPrompts::new(
Arc::new(move |p| prompt_events.try_send(SftpEvent::Prompt(p)).is_ok()),
shutdown.clone(),
));
let client: Arc<RwLock<Option<Arc<SftpClient>>>> = Arc::new(RwLock::new(None));
let busy = Arc::new(AtomicBool::new(false));
let router = {
let prompts = prompts.clone();
let events = events.clone();
let client = client.clone();
let busy = busy.clone();
let cancel_tx = cancel_tx.clone();
tokio::spawn(async move {
while let Ok(command) = commands.recv().await {
match command {
SftpCommand::PromptReply { request_id, reply } => {
prompts.respond(request_id, reply)
}
SftpCommand::Answer { request_id, answer } => {
let _ = answers_tx.send((request_id, answer)).await;
}
SftpCommand::Cancel => {
cancel_tx.send_replace(true);
prompts.cancel_all();
}
SftpCommand::Disconnect => {
cancel_tx.send_replace(true);
prompts.cancel_all();
*client.write().await = None;
let _ = events
.send(SftpEvent::Disconnected("SFTP 已断开".into()))
.await;
}
SftpCommand::Shutdown => break,
SftpCommand::List { request_id, path } => {
let shared = client.clone();
let connected = client.read().await.clone();
let events = events.clone();
tokio::spawn(async move {
let result = match connected {
Some(connected) => match connected.list(&path).await {
Ok(listing) => Ok(listing),
Err(error) => {
drop_broken(&shared, &connected, &events, &error).await;
Err(format!("无法读取目录:{error:#}"))
}
},
None => Err("SFTP 未连接,请重新连接".into()),
};
let _ = events.send(SftpEvent::Listed { request_id, result }).await;
});
}
SftpCommand::Operate {
request_id,
operation,
} => {
let shared = client.clone();
let connected = client.read().await.clone();
let events = events.clone();
tokio::spawn(async move {
let result = match connected {
Some(connected) => {
match operations::run(connected.as_ref(), &operation).await
{
Ok(()) => Ok(()),
Err(error) => {
drop_broken(&shared, &connected, &events, &error)
.await;
Err(format!("{error:#}"))
}
}
}
None => Err("SFTP 未连接,请重新连接".into()),
};
let _ = events
.send(SftpEvent::Operated { request_id, result })
.await;
});
}
command => {
if busy.swap(true, Ordering::AcqRel) {
let _ = events
.send(SftpEvent::Notice("已有传输批次正在处理".into()))
.await;
} else {
cancel_tx.send_replace(false);
if operations_tx.send(command).await.is_err() {
break;
}
}
}
}
}
shutdown_tx.send_replace(true);
cancel_tx.send_replace(true);
prompts.cancel_all();
})
};
let latency = tokio::spawn(measure_latency(client.clone(), events.clone()));
let control = TransferControl {
events: events.clone(),
cancel,
answers,
};
let mut batch: Option<TransferBatch> = None;
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
let _ = events
.send(SftpEvent::Disconnected(describe_login_error(&error)))
.await;
}
}
let mut stop = shutdown.clone();
while !*stop.borrow() {
// Weakly: a client dropped on 断开 must still close its connection.
let watched = client
.read()
.await
.as_ref()
.map(|connected| (Arc::downgrade(connected), connected.closed()));
let (watched, closed) = watched.unzip();
let operation = tokio::select! {
operation = operations.recv() => match operation {
Ok(operation) => operation,
Err(_) => break,
},
_ = stop.changed() => break,
// An idle connection that drops says so at once, as a
// terminal on the same host does, not at the next request.
_ = async {
match closed {
Some(closed) => closed.await,
None => std::future::pending().await,
}
} => {
if let Some(connected) = watched.and_then(|weak| weak.upgrade()) {
forget(&client, &connected, &events).await;
}
continue;
}
};
if client.read().await.is_none() {
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
events
.send(SftpEvent::Disconnected(describe_login_error(&error)))
.await?;
events.send(SftpEvent::Idle).await?;
busy.store(false, Ordering::Release);
continue;
}
}
}
let connected = client.read().await.clone().expect("connected above");
let direction = match &operation {
SftpCommand::Download(_) => TransferDirection::Download,
SftpCommand::Upload(_) => TransferDirection::Upload,
_ => batch
.as_ref()
.map(|batch| match batch {
TransferBatch::Upload(_) => TransferDirection::Upload,
TransferBatch::Download(_) => TransferDirection::Download,
})
.unwrap_or_default(),
};
let prepared: Result<()> = async {
if let SftpCommand::Upload(request) = &operation {
batch = None;
events
.send(SftpEvent::Progress(TransferProgress::default()))
.await?;
let request = if request.is_scp() {
let destination = request.destination();
let is_directory = destination.is_root()
|| control
.run(connected.stat(destination))
.await?
.is_some_and(|metadata| metadata.kind() == EntryKind::Directory);
let (directory, name) = scp_remote_target(destination, is_directory);
// Some servers resolve a path that is not there, so
// ask whether it is a directory too.
let missing = || anyhow!("远程目录 {directory} 不存在");
let resolved = control
.run(connected.canonicalize(&directory))
.await
.map_err(|_| missing())?;
if !control
.run(connected.stat(&resolved))
.await?
.is_some_and(|metadata| metadata.kind() == EntryKind::Directory)
{
return Err(missing());
}
request.clone().resolved(resolved, name)
} else {
let target = control
.run(connected.canonicalize(request.destination()))
.await?;
super::UploadRequest::new(request.sources().to_vec(), target)?
};
batch = Some(TransferBatch::Upload(
control
.run(UploadBatch::scan(
&request,
&self.config.endpoint(),
connected.fingerprint(),
self.journal.clone(),
&control,
))
.await?,
));
}
if let SftpCommand::Download(request) = &operation {
batch = None;
let request = &if request.is_scp() {
let destination = request.destination();
let is_directory = tokio::fs::metadata(destination)
.await
.is_ok_and(|metadata| metadata.is_dir());
let (directory, name) = scp_local_target(destination, is_directory);
if !tokio::fs::metadata(&directory)
.await
.is_ok_and(|metadata| metadata.is_dir())
{
anyhow::bail!("本地目录 {} 不存在", directory.display());
}
request.clone().resolved(directory, name)
} else {
request.clone()
};
events
.send(SftpEvent::Progress(TransferProgress {
direction: TransferDirection::Download,
..TransferProgress::default()
}))
.await?;
batch = Some(TransferBatch::Download(
DownloadBatch::scan(
request,
&self.config.endpoint(),
connected.fingerprint(),
self.download_journal.clone(),
connected.as_ref(),
&control,
)
.await?,
));
}
if let Some(batch) = &batch {
batch.verify_host(connected.fingerprint())?;
}
if matches!(operation, SftpCommand::Discard) {
if let Some(current) = &batch {
current.discard(connected.as_ref()).await?;
}
batch = None;
events
.send(SftpEvent::Progress(TransferProgress {
direction,
phase: TransferPhase::Completed,
..Default::default()
}))
.await?;
}
Ok(())
}
.await;
if let Err(error) = prepared {
events.send(SftpEvent::Notice(error.to_string())).await?;
if let Some(batch) = &mut batch {
batch.phase(TransferPhase::Stopped, &control);
} else {
events
.send(SftpEvent::Progress(TransferProgress {
direction,
phase: TransferPhase::Stopped,
details: vec![TransferDetail::failed("", error.to_string())],
..Default::default()
}))
.await?;
}
// Done, as far as whoever sent the batch is concerned.
events.send(SftpEvent::Idle).await?;
busy.store(false, Ordering::Release);
continue;
}
if !matches!(operation, SftpCommand::Discard)
&& let Some(batch) = &mut batch
{
let mut reconnects = 0;
loop {
let connected = client.read().await.clone();
let result = match connected {
Some(connected) => batch.run(connected.as_ref(), &control).await,
None => {
if control.check().is_err() {
Err(Cancelled.into())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"SFTP 未连接",
)
.into())
}
}
};
match result {
Ok(()) => break,
Err(error) if is_network_error(&error) => {
*client.write().await = None;
events
.send(SftpEvent::Disconnected("连接中断,传输进度已保留".into()))
.await?;
let mut recovered = false;
while reconnects < 3 {
batch.set_note(format!(
"将在 {} 秒后重连({}/3)",
[1, 3, 10][reconnects],
reconnects + 1
));
batch.phase(TransferPhase::Reconnecting, &control);
let delay = Duration::from_secs([1, 3, 10][reconnects]);
reconnects += 1;
if control
.run(async {
tokio::time::sleep(delay).await;
Ok(())
})
.await
.is_err()
{
break;
}
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(next) => match batch.verify_host(next.fingerprint()) {
Ok(()) => {
*client.write().await = Some(next);
recovered = true;
break;
}
Err(error) => {
events
.send(SftpEvent::Notice(error.to_string()))
.await?;
break;
}
},
Err(error) => {
events
.send(SftpEvent::Disconnected(error.to_string()))
.await?;
// Authentication/trust failures must require user action.
if !is_network_error(&error) {
break;
}
}
}
}
if recovered {
continue;
}
batch.phase(TransferPhase::Stopped, &control);
break;
}
Err(error) => {
if !error.is::<Cancelled>() {
events.send(SftpEvent::Notice(error.to_string())).await?;
}
batch.phase(TransferPhase::Stopped, &control);
break;
}
}
}
if batch.is_complete() {
batch.phase(TransferPhase::Completed, &control);
}
}
busy.store(false, Ordering::Release);
events.send(SftpEvent::Idle).await?;
}
router.abort();
latency.abort();
*client.write().await = None;
Ok(())
}
}
/// Measure the connection in use every [`LATENCY_INTERVAL`], and a new one
/// at once, transfers or not. The client is held only for a ping, and let go
/// as soon as it is no longer the one in use, so 断开 still closes the
/// connection at once.
async fn measure_latency(shared: Arc<RwLock<Option<Arc<SftpClient>>>>, events: Sender<SftpEvent>) {
let mut measured = Weak::new();
let mut last = Instant::now();
loop {
tokio::time::sleep(LATENCY_POLL).await;
let Some(connected) = shared.read().await.clone() else {
continue;
};
let fresh = !Weak::ptr_eq(&measured, &Arc::downgrade(&connected));
if !fresh && last.elapsed() < LATENCY_INTERVAL {
continue;
}
measured = Arc::downgrade(&connected);
last = Instant::now();
let latency = tokio::select! {
latency = connected.round_trip() => latency,
() = replaced(&shared, &connected) => None,
};
drop(connected);
if let Some(latency) = latency
&& events.send(SftpEvent::Latency(latency)).await.is_err()
{
break;
}
}
}
/// Resolves once `connected` is no longer the client in use.
async fn replaced(shared: &RwLock<Option<Arc<SftpClient>>>, connected: &Arc<SftpClient>) {
loop {
tokio::time::sleep(LATENCY_POLL).await;
let current = shared.read().await;
if !current
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, connected))
{
return;
}
}
}
/// A request failed: if the connection broke, `forget` it.
async fn drop_broken(
shared: &RwLock<Option<Arc<SftpClient>>>,
connected: &Arc<SftpClient>,
events: &Sender<SftpEvent>,
error: &anyhow::Error,
) {
if is_network_error(error) {
forget(shared, connected, events).await;
}
}
/// The connection is gone: forget that client (unless a newer one already
/// replaced it) and tell the panel once.
async fn forget(
shared: &RwLock<Option<Arc<SftpClient>>>,
connected: &Arc<SftpClient>,
events: &Sender<SftpEvent>,
) {
let mut current = shared.write().await;
if current.as_ref().is_some_and(|c| Arc::ptr_eq(c, connected)) {
*current = None;
let _ = events
.send(SftpEvent::Disconnected("SFTP 连接中断,请重新连接".into()))
.await;
}
}