feat: harden local shell, SFTP, and desktop session reliability

Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

+1 -1
View File
@@ -315,7 +315,7 @@ fn validate_provider(provider: &AiProviderConfig, api_key: Option<&str>) -> Resu
if provider.id.trim().is_empty() || provider.name.trim().is_empty() {
return Err("Provider 配置无效".to_string());
}
if provider.auth_style != "none" && api_key.map_or(true, str::is_empty) {
if provider.auth_style != "none" && api_key.is_none_or(str::is_empty) {
return Err(format!("Provider「{}」未保存 API Key", provider.name));
}
Ok(())
+24
View File
@@ -33,6 +33,8 @@ pub enum DesktopInput {
ClipboardSet { text: String },
/// Place a file into the host Downloads folder for RDP RDPDR.
UploadFile { name: String, data: Vec<u8> },
/// Request remote desktop resize via RDP Display Control (ignored by VNC).
Resize { width: u16, height: u16 },
}
/// Coalesce dirty rects and rate-limit IPC emits (~30 FPS).
@@ -69,6 +71,11 @@ impl FrameEmitter {
});
}
/// Drop coalesced dirty rects (e.g. after DisplayControl resize / new framebuffer).
pub fn clear_pending(&mut self) {
self.pending = None;
}
pub fn flush_if_due(
&mut self,
app: &AppHandle,
@@ -86,6 +93,16 @@ impl FrameEmitter {
let Some((left, top, right, bottom)) = self.pending.take() else {
return;
};
// Clamp to current framebuffer — stale rects after resize must not be extracted.
if fb_width == 0 || fb_height == 0 {
return;
}
let max_x = fb_width.saturating_sub(1);
let max_y = fb_height.saturating_sub(1);
let left = left.min(max_x);
let top = top.min(max_y);
let right = right.min(max_x).max(left);
let bottom = bottom.min(max_y).max(top);
let width = right.saturating_sub(left).saturating_add(1);
let height = bottom.saturating_sub(top).saturating_add(1);
let Some(rgba) = rgba_extractor(left, top, width, height) else {
@@ -206,11 +223,15 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
}
let mut out = Vec::with_capacity(queued.len());
let mut pending_move: Option<DesktopInput> = None;
let mut pending_resize: Option<DesktopInput> = None;
for event in queued.drain(..) {
match &event {
DesktopInput::Pointer { moving: true, .. } => {
pending_move = Some(event);
}
DesktopInput::Resize { .. } => {
pending_resize = Some(event);
}
_ => {
if let Some(mv) = pending_move.take() {
out.push(mv);
@@ -222,6 +243,9 @@ pub fn coalesce_desktop_inputs(mut queued: Vec<DesktopInput>) -> Vec<DesktopInpu
if let Some(mv) = pending_move {
out.push(mv);
}
if let Some(resize) = pending_resize {
out.push(resize);
}
out
}
+7
View File
@@ -100,6 +100,13 @@ impl RdpService {
self.push_input(session_id, DesktopInput::UploadFile { name, data })
}
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
if !(200..=8192).contains(&width) || !(200..=8192).contains(&height) {
return Err("分辨率超出允许范围 (200–8192)".into());
}
self.push_input(session_id, DesktopInput::Resize { width, height })
}
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
if let Some(session) = self
.sessions
+426 -38
View File
@@ -13,8 +13,14 @@ use std::{
use anyhow::Context as _;
use ironrdp::cliprdr::backend::ClipboardMessage;
use ironrdp::cliprdr::{Cliprdr, CliprdrClient};
use ironrdp::connector::{self, ConnectionResult, Credentials};
use ironrdp::connector::connection_activation::{
ConnectionActivationFactory, ConnectionActivationState,
};
use ironrdp::connector::{self, ConnectionResult, Credentials, Sequence as _};
use ironrdp::core::WriteBuf;
use ironrdp::displaycontrol::client::DisplayControlClient;
use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
use ironrdp::dvc::DrdynvcClient;
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
use ironrdp::pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags};
@@ -25,8 +31,10 @@ use ironrdp::pdu::rdp::headers::ShareDataPdu;
use ironrdp::pdu::rdp::suppress_output::SuppressOutputPdu;
use ironrdp::rdpdr::Rdpdr;
use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd};
use ironrdp::session::fast_path::ProcessorBuilder;
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{ActiveStage, ActiveStageBuilder, ActiveStageOutput};
use ironrdp_bulk::BulkCompressor;
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
use tauri::AppHandle;
use tokio_rustls::rustls;
@@ -174,8 +182,15 @@ fn run_session_inner(
return Ok(());
}
let fb_width = connection_result.desktop_size.width;
let fb_height = connection_result.desktop_size.height;
let mut fb_width = connection_result.desktop_size.width;
let mut fb_height = connection_result.desktop_size.height;
let io_channel_id = connection_result.io_channel_id;
let user_channel_id = connection_result.user_channel_id;
let compression_type = connection_result.compression_type;
let mut share_id = connection_result.share_id;
let activation_factory = connection_result.activation_factory;
let mut enable_server_pointer = connection_result.enable_server_pointer;
let mut pointer_software_rendering = connection_result.pointer_software_rendering;
let mut image = DecodedImage::new(
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
@@ -185,13 +200,13 @@ fn run_session_inner(
let mut active_stage = ActiveStageBuilder {
static_channels: connection_result.static_channels,
user_channel_id: connection_result.user_channel_id,
io_channel_id: connection_result.io_channel_id,
user_channel_id,
io_channel_id,
message_channel_id: connection_result.message_channel_id,
share_id: connection_result.share_id,
compression_type: connection_result.compression_type,
enable_server_pointer: connection_result.enable_server_pointer,
pointer_software_rendering: connection_result.pointer_software_rendering,
share_id,
compression_type,
enable_server_pointer,
pointer_software_rendering,
}
.build();
@@ -219,6 +234,7 @@ fn run_session_inner(
// Avoid shipping a full black framebuffer over IPC; wait for real GraphicsUpdate.
let mut frame_emitter = FrameEmitter::new("rdp:frame", session_id);
let mut pending_resize: Option<(u16, u16)> = None;
loop {
if stop.load(Ordering::Relaxed) {
@@ -236,6 +252,23 @@ fn run_session_inner(
&clip_outbound,
&want_paste_inject,
&mut frame_emitter,
&mut fb_width,
&mut fb_height,
&mut share_id,
&mut enable_server_pointer,
&mut pointer_software_rendering,
&activation_factory,
io_channel_id,
user_channel_id,
compression_type,
&mut pending_resize,
)?;
try_pending_resize(
&mut active_stage,
&mut framed,
&mut pending_resize,
fb_width,
fb_height,
)?;
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
if pending_paste_inject.swap(false, Ordering::Relaxed) {
@@ -246,6 +279,15 @@ fn run_session_inner(
&mut image,
&mut framed,
&mut frame_emitter,
&mut fb_width,
&mut fb_height,
&mut share_id,
&mut enable_server_pointer,
&mut pointer_software_rendering,
&activation_factory,
io_channel_id,
user_channel_id,
compression_type,
)?;
}
drive_cliprdr_timeouts(&mut active_stage, &mut framed)?;
@@ -264,9 +306,31 @@ fn run_session_inner(
Err(error) => return Err(anyhow::Error::new(error).context("读取 RDP 帧失败")),
};
let outputs = active_stage
.process(&mut image, action, &payload)
.map_err(|e| anyhow::anyhow!("处理 RDP 帧失败: {e}"))?;
let outputs = match active_stage.process(&mut image, action, &payload) {
Ok(outputs) => outputs,
Err(error) => {
let text = error.to_string();
if text.contains("bulk decompression failed") {
// Prefer surviving a single bad compressed PDU over killing the session
// (history desync after resize is the usual trigger).
warn!(%text, "RDP bulk decompression failed — resetting FastPath decompressor");
frame_emitter.clear_pending();
active_stage.set_fastpath_processor(
ProcessorBuilder {
io_channel_id,
user_channel_id,
share_id,
enable_server_pointer,
pointer_software_rendering,
bulk_decompressor: build_bulk_decompressor(compression_type),
}
.build(),
);
continue;
}
return Err(map_fast_path_error(error));
}
};
// Backend callbacks during `process` may queue CLIPRDR replies (paste / format data).
drain_cliprdr_outbound(&mut active_stage, &mut framed, &clip_outbound)?;
@@ -278,6 +342,15 @@ fn run_session_inner(
&mut image,
&mut framed,
&mut frame_emitter,
&mut fb_width,
&mut fb_height,
&mut share_id,
&mut enable_server_pointer,
&mut pointer_software_rendering,
&activation_factory,
io_channel_id,
user_channel_id,
compression_type,
)?;
}
@@ -285,9 +358,17 @@ fn run_session_inner(
app,
session_id,
&mut framed,
&image,
fb_width,
fb_height,
&mut active_stage,
&mut image,
&mut fb_width,
&mut fb_height,
&mut share_id,
&mut enable_server_pointer,
&mut pointer_software_rendering,
&activation_factory,
io_channel_id,
user_channel_id,
compression_type,
outputs,
&mut frame_emitter,
)? {
@@ -368,6 +449,16 @@ fn drain_and_send_input(
clip_outbound: &Mutex<Vec<ClipboardMessage>>,
want_paste_inject: &AtomicBool,
frame_emitter: &mut FrameEmitter,
fb_width: &mut u16,
fb_height: &mut u16,
share_id: &mut u32,
enable_server_pointer: &mut bool,
pointer_software_rendering: &mut bool,
activation_factory: &ConnectionActivationFactory,
io_channel_id: u16,
user_channel_id: u16,
compression_type: Option<CompressionType>,
pending_resize: &mut Option<(u16, u16)>,
) -> anyhow::Result<()> {
let queued = {
let mut guard = input
@@ -379,8 +470,6 @@ fn drain_and_send_input(
return Ok(());
}
let fb_width = image.width();
let fb_height = image.height();
let mut events = Vec::new();
for event in queued {
match event {
@@ -403,8 +492,8 @@ fn drain_and_send_input(
session_id,
"connected",
Some(error),
Some(fb_width),
Some(fb_height),
Some(*fb_width),
Some(*fb_height),
);
} else {
emit_status(
@@ -413,13 +502,24 @@ fn drain_and_send_input(
session_id,
"connected",
Some("已放入共享盘「下载」,远端此电脑可打开".into()),
Some(fb_width),
Some(fb_height),
Some(*fb_width),
Some(*fb_height),
);
}
}
}
}
DesktopInput::Resize { width, height } => {
queue_or_send_resize(
active_stage,
framed,
pending_resize,
*fb_width,
*fb_height,
width,
height,
)?;
}
other => {
if let Some(fast) = map_desktop_input(other) {
events.push(fast);
@@ -438,9 +538,17 @@ fn drain_and_send_input(
app,
session_id,
framed,
active_stage,
image,
fb_width,
fb_height,
share_id,
enable_server_pointer,
pointer_software_rendering,
activation_factory,
io_channel_id,
user_channel_id,
compression_type,
outputs,
frame_emitter,
)?;
@@ -454,6 +562,15 @@ fn inject_remote_paste(
image: &mut DecodedImage,
framed: &mut UpgradedFramed,
frame_emitter: &mut FrameEmitter,
fb_width: &mut u16,
fb_height: &mut u16,
share_id: &mut u32,
enable_server_pointer: &mut bool,
pointer_software_rendering: &mut bool,
activation_factory: &ConnectionActivationFactory,
io_channel_id: u16,
user_channel_id: u16,
compression_type: Option<CompressionType>,
) -> anyhow::Result<()> {
// Left Ctrl (0x1D) + V (0x2F) — Windows paste after CLIPRDR FormatList accepted.
let events = [
@@ -462,8 +579,6 @@ fn inject_remote_paste(
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x2F),
FastPathInputEvent::KeyboardEvent(KeyboardFlags::RELEASE, 0x1D),
];
let fb_width = image.width();
let fb_height = image.height();
let outputs = active_stage
.process_fastpath_input(image, &events)
.map_err(|e| anyhow::anyhow!("注入粘贴失败: {e}"))?;
@@ -471,9 +586,17 @@ fn inject_remote_paste(
app,
session_id,
framed,
active_stage,
image,
fb_width,
fb_height,
share_id,
enable_server_pointer,
pointer_software_rendering,
activation_factory,
io_channel_id,
user_channel_id,
compression_type,
outputs,
frame_emitter,
)?;
@@ -535,7 +658,9 @@ fn map_desktop_input(event: DesktopInput) -> Option<FastPathInputEvent> {
}
Some(FastPathInputEvent::KeyboardEvent(flags, scan))
}
DesktopInput::ClipboardSet { .. } | DesktopInput::UploadFile { .. } => None,
DesktopInput::ClipboardSet { .. }
| DesktopInput::UploadFile { .. }
| DesktopInput::Resize { .. } => None,
}
}
@@ -628,13 +753,22 @@ fn apply_outputs(
app: &AppHandle,
session_id: &str,
framed: &mut UpgradedFramed,
image: &DecodedImage,
fb_width: u16,
fb_height: u16,
active_stage: &mut ActiveStage,
image: &mut DecodedImage,
fb_width: &mut u16,
fb_height: &mut u16,
share_id: &mut u32,
enable_server_pointer: &mut bool,
pointer_software_rendering: &mut bool,
activation_factory: &ConnectionActivationFactory,
io_channel_id: u16,
user_channel_id: u16,
compression_type: Option<CompressionType>,
outputs: Vec<ActiveStageOutput>,
frame_emitter: &mut FrameEmitter,
) -> anyhow::Result<bool> {
let mut terminate = false;
let mut need_reactivate = false;
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => {
@@ -649,15 +783,239 @@ fn apply_outputs(
terminate = true;
}
ActiveStageOutput::DeactivateAll => {
warn!(session_id, "RDP DeactivateAll received");
need_reactivate = true;
}
_ => {}
}
}
let _ = (app, image, fb_width, fb_height);
if need_reactivate {
frame_emitter.clear_pending();
reactivate_after_deactivate(
app,
session_id,
framed,
active_stage,
image,
fb_width,
fb_height,
share_id,
enable_server_pointer,
pointer_software_rendering,
activation_factory,
io_channel_id,
user_channel_id,
compression_type,
)?;
}
Ok(terminate)
}
fn queue_or_send_resize(
active_stage: &mut ActiveStage,
framed: &mut UpgradedFramed,
pending_resize: &mut Option<(u16, u16)>,
fb_width: u16,
fb_height: u16,
width: u16,
height: u16,
) -> anyhow::Result<()> {
let (adj_w, adj_h) =
MonitorLayoutEntry::adjust_display_size(u32::from(width), u32::from(height));
let width = u16::try_from(adj_w).unwrap_or(fb_width);
let height = u16::try_from(adj_h).unwrap_or(fb_height);
if width == fb_width && height == fb_height {
*pending_resize = None;
return Ok(());
}
match active_stage.encode_resize(u32::from(width), u32::from(height), Some(100), None) {
Some(Ok(frame)) => {
*pending_resize = None;
if !frame.is_empty() {
framed
.write_all(&frame)
.context("写入 DisplayControl 分辨率请求失败")?;
}
info!(width, height, "RDP DisplayControl resize requested");
}
Some(Err(error)) => {
return Err(anyhow::anyhow!("编码 DisplayControl 分辨率失败: {error}"));
}
None => {
debug!(width, height, "DisplayControl not ready — defer resize");
*pending_resize = Some((width, height));
}
}
Ok(())
}
fn try_pending_resize(
active_stage: &mut ActiveStage,
framed: &mut UpgradedFramed,
pending_resize: &mut Option<(u16, u16)>,
fb_width: u16,
fb_height: u16,
) -> anyhow::Result<()> {
let Some((width, height)) = *pending_resize else {
return Ok(());
};
queue_or_send_resize(
active_stage,
framed,
pending_resize,
fb_width,
fb_height,
width,
height,
)
}
fn map_fast_path_error(error: impl std::fmt::Display) -> anyhow::Error {
let text = error.to_string();
if text.contains("bulk decompression failed") {
anyhow::anyhow!(
"处理 RDP 帧失败: 远端图像压缩解压异常(常见于分辨率切换后)。请断开后重连。"
)
} else {
// Drop crate source paths like `[FastPath @ /Users/.../fast_path.rs:110]`.
let short = text
.split(" @ ")
.next()
.unwrap_or(text.as_str())
.trim_end_matches([']', ' ']);
anyhow::anyhow!("处理 RDP 帧失败: {short}")
}
}
fn build_bulk_decompressor(compression_type: Option<CompressionType>) -> Option<BulkCompressor> {
compression_type.and_then(|ct| {
let bulk_ct = match ct {
CompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4,
CompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5,
CompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6,
CompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61,
};
match BulkCompressor::new(bulk_ct) {
Ok(compressor) => Some(compressor),
Err(error) => {
warn!(%error, "Failed to recreate bulk decompressor after resize");
None
}
}
})
}
fn reactivate_after_deactivate(
app: &AppHandle,
session_id: &str,
framed: &mut UpgradedFramed,
active_stage: &mut ActiveStage,
image: &mut DecodedImage,
fb_width: &mut u16,
fb_height: &mut u16,
share_id: &mut u32,
enable_server_pointer: &mut bool,
pointer_software_rendering: &mut bool,
activation_factory: &ConnectionActivationFactory,
io_channel_id: u16,
user_channel_id: u16,
compression_type: Option<CompressionType>,
) -> anyhow::Result<()> {
info!(session_id, "RDP DeactivateAll — running reactivation");
set_stream_timeouts(
framed.get_inner_mut().0,
HANDSHAKE_TIMEOUT,
HANDSHAKE_TIMEOUT,
)?;
let mut sequence = activation_factory.create();
let mut buf = WriteBuf::new();
loop {
if matches!(
sequence.connection_activation_state(),
ConnectionActivationState::Finalized { .. }
) {
break;
}
buf.clear();
let written = if let Some(hint) = sequence.next_pdu_hint() {
let pdu = framed
.read_by_hint(hint)
.context("读取 Deactivation-Reactivation PDU 失败")?;
sequence
.step(&pdu, &mut buf)
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
} else {
sequence
.step_no_input(&mut buf)
.map_err(|error| anyhow::anyhow!("Deactivation-Reactivation 失败: {error}"))?
};
if let Some(response_len) = written.size() {
framed
.write_all(&buf[..response_len])
.context("写入 Deactivation-Reactivation 响应失败")?;
}
}
let ConnectionActivationState::Finalized {
desktop_size,
share_id: new_share_id,
enable_server_pointer: new_enable_server_pointer,
pointer_software_rendering: new_pointer_software_rendering,
} = sequence.connection_activation_state()
else {
return Err(anyhow::anyhow!("Deactivation-Reactivation 未完成"));
};
*fb_width = desktop_size.width;
*fb_height = desktop_size.height;
*share_id = new_share_id;
*enable_server_pointer = new_enable_server_pointer;
*pointer_software_rendering = new_pointer_software_rendering;
*image = DecodedImage::new(
ironrdp::graphics::image_processing::PixelFormat::RgbA32,
*fb_width,
*fb_height,
);
active_stage.set_share_id(*share_id);
active_stage.set_enable_server_pointer(*enable_server_pointer);
active_stage.set_fastpath_processor(
ProcessorBuilder {
io_channel_id,
user_channel_id,
share_id: *share_id,
enable_server_pointer: *enable_server_pointer,
pointer_software_rendering: *pointer_software_rendering,
bulk_decompressor: build_bulk_decompressor(compression_type),
}
.build(),
);
send_allow_display_updates(active_stage, framed, *fb_width, *fb_height)
.context("重激活后发送 SuppressOutput 失败")?;
set_stream_timeouts(
framed.get_inner_mut().0,
ACTIVE_READ_TIMEOUT,
ACTIVE_WRITE_TIMEOUT,
)?;
emit_status(
app,
"rdp:status",
session_id,
"connected",
Some(format!("分辨率已更新 {}×{}", fb_width, fb_height)),
Some(*fb_width),
Some(*fb_height),
);
info!(
session_id,
width = *fb_width,
height = *fb_height,
"RDP reactivation complete"
);
Ok(())
}
fn flush_pending_frames(
app: &AppHandle,
frame_emitter: &mut FrameEmitter,
@@ -678,7 +1036,7 @@ fn flush_pending_frames(
right: left.saturating_add(width.saturating_sub(1)),
bottom: top.saturating_add(height.saturating_sub(1)),
};
Some(extract_rgba_rect(image, &rect))
extract_rgba_rect(image, &rect)
},
fb_width,
fb_height,
@@ -686,19 +1044,36 @@ fn flush_pending_frames(
);
}
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Vec<u8> {
let w = usize::from(rect.width());
let h = usize::from(rect.height());
fn extract_rgba_rect(image: &DecodedImage, rect: &InclusiveRectangle) -> Option<Vec<u8>> {
let fb_w = usize::from(image.width());
let fb_h = usize::from(image.height());
if fb_w == 0 || fb_h == 0 {
return None;
}
let left = usize::from(rect.left).min(fb_w.saturating_sub(1));
let top = usize::from(rect.top).min(fb_h.saturating_sub(1));
let right = usize::from(rect.right)
.min(fb_w.saturating_sub(1))
.max(left);
let bottom = usize::from(rect.bottom)
.min(fb_h.saturating_sub(1))
.max(top);
let w = right - left + 1;
let h = bottom - top + 1;
let bpp = image.bytes_per_pixel();
let stride = image.stride();
let data = image.data();
let mut out = Vec::with_capacity(w * h * bpp);
for row in 0..h {
let y = usize::from(rect.top) + row;
let start = y * stride + usize::from(rect.left) * bpp;
out.extend_from_slice(&data[start..start + w * bpp]);
let y = top + row;
let start = y * stride + left * bpp;
let end = start + w * bpp;
if end > data.len() {
return None;
}
out.extend_from_slice(&data[start..end]);
}
out
Some(out)
}
fn build_config(
@@ -748,7 +1123,11 @@ fn build_config(
request_data: None,
autologon: false,
enable_audio_playback: false,
compression_type: Some(CompressionType::Rdp61),
// Prefer RDP5 MPPC (64 KB) over RDP6.1 XCRUSH. IronRDP's XCRUSH path is stateful and
// commonly fails after DisplayControl resize / DeactivateAll ("match output offset out
// of order"), dropping the whole session. MPPC is what IronRDP itself recommends until
// slow-path bulk decompression is fully wired.
compression_type: Some(CompressionType::K64),
// Hardware/server pointer + CSS cursor on the canvas reduces framebuffer churn.
pointer_software_rendering: false,
multitransport_flags: None,
@@ -811,8 +1190,17 @@ fn connect(
)
.with_drives(Some(vec![(1, super::rdpdr_fs::DRIVE_NAME.into())]));
let rdpsnd = Rdpsnd::new(Box::new(NoopRdpsndBackend));
let display_control = DisplayControlClient::new(|caps| {
debug!(
max_area = caps.max_monitor_area(),
"RDP DisplayControl capabilities"
);
Ok(Vec::new())
});
let drdynvc = DrdynvcClient::new().with_dynamic_channel(display_control);
let mut connector = connector::ClientConnector::new(config, client_addr)
.with_static_channel(drdynvc)
.with_static_channel(rdpsnd)
.with_static_channel(rdpdr)
.with_static_channel(cliprdr);
+1 -1
View File
@@ -243,7 +243,7 @@ async fn drain_input(
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
inject_remote_paste(vnc).await?;
}
DesktopInput::UploadFile { .. } => {}
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
}
}
Ok(())
+608 -112
View File
@@ -1,35 +1,54 @@
//! Device-bound Pro license helpers (HMAC signed tokens).
//! Signing secret stays on license-server; desktop embeds the matching
//! verify key for offline activate (rotate for production).
//! Pro license persistence + thin-client activate against opsx-licence.
//! Runtime entitlement cache lives in `license.opsx`; online verify is remote,
//! offline `LICOFF-` serials are Ed25519 self-contained.
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use serde_json::Value;
use tauri::{AppHandle, Manager};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
/// Public opsx-licence API root (must end with `/api`, never include `/v1`). // Logan.Li
const DEFAULT_API_BASE: &str = "https://l123.91ai.io/api";
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
/// Offline license verifying key (hex). Must match server `OFFLINE_LICENSE_SEED`. // Logan.Li
const OFFLINE_LICENSE_PUBLIC_KEY_HEX: &str =
"108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LicensePayload {
pub plan: String,
pub device_id: String,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
struct StoredLicense {
#[serde(default)]
pub features: Vec<String>,
license_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(default = "default_plan")]
plan: String,
#[serde(default)]
activated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<u64>,
#[serde(default)]
features: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_devices: Option<u32>,
/// yearly | perpetual | trial | …
#[serde(default, skip_serializing_if = "Option::is_none")]
plan_type: Option<String>,
#[serde(default)]
last_verified_at: u64,
}
fn default_plan() -> String {
"pro".to_string()
}
#[derive(Debug, Clone, Serialize)]
@@ -41,12 +60,43 @@ pub struct LicenseStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
pub features: Vec<String>,
/// License key string (compat field name for existing frontend).
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_devices: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_type: Option<String>,
}
fn hmac_secret() -> String {
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
/// Normalize license API base so paths like `/v1/license/activate` resolve correctly.
/// Accepts `https://host`, `https://host/api`, or mistaken `…/api/v1`. // Logan.Li
fn normalize_api_base(raw: &str) -> String {
let mut base = raw.trim().trim_end_matches('/').to_string();
if base.is_empty() {
return DEFAULT_API_BASE.to_string();
}
// Prefer https for the public licence host (http→301 HTML breaks JSON clients). // Logan.Li
if base.starts_with("http://l123.91ai.io") {
base = format!("https{}", &base["http".len()..]);
}
if let Some(stripped) = base.strip_suffix("/api/v1") {
base = format!("{stripped}/api");
} else if base.ends_with("/v1") && !base.ends_with("/api/v1") {
// e.g. https://l123.91ai.io/v1 → https://l123.91ai.io/api
base = format!("{}/api", base.trim_end_matches("/v1").trim_end_matches('/'));
} else if !base.ends_with("/api") {
base = format!("{base}/api");
}
base
}
fn api_base() -> String {
let raw =
std::env::var("OPSX_LICENSE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
normalize_api_base(&raw)
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
@@ -70,6 +120,13 @@ fn ensure_parent(path: &Path) -> Result<(), String> {
Ok(())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
let path = device_id_path(app)?;
if path.exists() {
@@ -85,112 +142,499 @@ pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
Ok(id)
}
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|error| format!("HMAC 初始化失败: {error}"))?;
mac.update(payload_b64.as_bytes());
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
fn free_status(device_id: String, email: Option<String>) -> LicenseStatus {
LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
email,
max_devices: None,
plan_type: None,
}
}
/// Allow helpers used by unit tests / future issue endpoints.
#[allow(dead_code)]
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
let sig = sign_payload(&payload_b64, secret)?;
Ok(format!("opsx1.{payload_b64}.{sig}"))
}
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
let trimmed = token.trim();
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() != 3 || parts[0] != "opsx1" {
return Err("许可证格式无效".to_string());
fn status_from_stored(device_id: String, stored: &StoredLicense) -> LicenseStatus {
if !stored.activated || stored.license_key.trim().is_empty() {
return free_status(device_id, stored.email.clone());
}
let expected = sign_payload(parts[1], secret)?;
if expected != parts[2] {
return Err("许可证签名无效".to_string());
}
let bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| "许可证载荷损坏".to_string())?;
let payload: LicensePayload =
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
if payload.plan != "pro" {
return Err("许可证计划无效".to_string());
}
if let Some(exp) = payload.exp {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now > exp {
return Err("许可证已过期".to_string());
if let Some(exp) = stored.expires_at {
if exp > 0 && exp < now_secs() {
return free_status(device_id, stored.email.clone());
}
}
Ok(payload)
LicenseStatus {
plan: if stored.plan.is_empty() {
"pro".to_string()
} else {
stored.plan.clone()
},
device_id,
activated: true,
expires_at: stored.expires_at,
features: if stored.features.is_empty() {
vec!["*".to_string()]
} else {
stored.features.clone()
},
token: Some(stored.license_key.clone()),
email: stored.email.clone(),
max_devices: stored.max_devices,
plan_type: stored.plan_type.clone(),
}
}
fn read_stored(path: &Path) -> Result<Option<StoredLicense>, String> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(|error| format!("读取许可证失败: {error}"))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
// Legacy: plain opsx1 token file — treat as absent (must re-activate with LIC-).
if trimmed.starts_with("opsx1.") || !trimmed.starts_with('{') {
return Ok(None);
}
let stored: StoredLicense =
serde_json::from_str(trimmed).map_err(|error| format!("许可证文件损坏: {error}"))?;
Ok(Some(stored))
}
fn write_stored(path: &Path, stored: &StoredLicense) -> Result<(), String> {
ensure_parent(path)?;
let json = serde_json::to_string_pretty(stored)
.map_err(|error| format!("序列化许可证失败: {error}"))?;
fs::write(path, json).map_err(|error| format!("保存许可证失败: {error}"))
}
fn reject_legacy_key(license_key: &str) -> Result<(), String> {
let trimmed = license_key.trim();
if trimmed.is_empty() {
return Err("请输入许可证密钥".to_string());
}
if trimmed.starts_with("opsx1.") || trimmed.contains(".opsx1.") {
return Err("不支持旧版 opsx1 许可证,请使用 LIC- / LICOFF- 授权码".to_string());
}
if trimmed.starts_with("MII") {
return Err("看起来像私钥,请粘贴 LIC- / LICOFF- 许可证密钥".to_string());
}
Ok(())
}
fn is_offline_license_key(license_key: &str) -> bool {
license_key.trim().starts_with("LICOFF-")
}
#[derive(Debug, Deserialize)]
struct OfflineClaims {
#[serde(default)]
v: i32,
#[serde(rename = "pid")]
product_code: String,
#[serde(rename = "did")]
device_id: String,
#[serde(default)]
email: Option<String>,
#[serde(rename = "pt")]
plan_type: String,
iat: i64,
exp: i64,
#[serde(rename = "md", default)]
max_devices: i32,
}
fn verify_offline_serial(serial: &str, device_id: &str) -> Result<OfflineClaims, String> {
let trimmed = serial.trim();
let body = trimmed
.strip_prefix("LICOFF-1.")
.ok_or_else(|| "不是合法的离线序列号(需 LICOFF-1. 前缀)".to_string())?;
let (payload_b64, sig_b64) = body
.rsplit_once('.')
.ok_or_else(|| "离线序列号格式错误".to_string())?;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let sig_bytes = engine
.decode(sig_b64)
.map_err(|_| "离线序列号签名无法解码".to_string())?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "离线序列号签名无效".to_string())?;
let pub_bytes = hex::decode(OFFLINE_LICENSE_PUBLIC_KEY_HEX)
.map_err(|_| "离线验签公钥配置错误".to_string())?;
let verifying_key = VerifyingKey::from_bytes(
pub_bytes
.as_slice()
.try_into()
.map_err(|_| "离线验签公钥长度错误".to_string())?,
)
.map_err(|_| "离线验签公钥无效".to_string())?;
verifying_key
.verify(payload_b64.as_bytes(), &sig)
.map_err(|_| "离线序列号签名校验失败".to_string())?;
let payload = engine
.decode(payload_b64)
.map_err(|_| "离线序列号载荷无法解码".to_string())?;
let claims: OfflineClaims =
serde_json::from_slice(&payload).map_err(|_| "离线序列号载荷无法解析".to_string())?;
if claims.v != 0 && claims.v != 1 {
return Err("不支持的离线证版本".to_string());
}
if claims.product_code != "opsxshell" {
return Err("离线证产品不匹配".to_string());
}
if claims.device_id != device_id {
return Err("离线证与当前设备不匹配".to_string());
}
let plan = claims.plan_type.as_str();
if plan != "trial" && plan != "yearly" {
return Err("离线证计划无效(仅体验/包年)".to_string());
}
let now = now_secs() as i64;
if claims.exp <= now {
return Err("离线许可证已过期".to_string());
}
if claims.exp - claims.iat > 365 * 86400 + 3600 {
return Err("离线许可证有效期非法".to_string());
}
Ok(claims)
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.user_agent("OpsXShell/2")
.build()
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))
}
fn envelope_message(value: &Value) -> Option<String> {
value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn envelope_code(value: &Value) -> Option<i64> {
value.get("code").and_then(|v| v.as_i64())
}
fn envelope_data(value: &Value) -> &Value {
value.get("data").unwrap_or(value)
}
fn json_string_field(value: &Value, snake: &str, camel: &str) -> Option<String> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn json_i64_field(value: &Value, snake: &str, camel: &str) -> Option<i64> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
}
fn json_string_array(value: &Value, snake: &str, camel: &str) -> Vec<String> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn expires_from_days_left(days_left: Option<i64>) -> Option<u64> {
let days = days_left?;
if days < 0 {
return Some(0);
}
// Treat very long remaining windows as perpetual.
if days > 36_500 {
return None;
}
Some(now_secs().saturating_add((days as u64).saturating_mul(86_400)))
}
fn post_json(path: &str, body: Value) -> Result<Value, String> {
let url = format!("{}{path}", api_base());
let client = http_client()?;
let response = client
.post(&url)
.json(&body)
.send()
.map_err(|error| format!("许可证服务请求失败: {error}"))?;
let status = response.status();
let text = response
.text()
.map_err(|error| format!("读取许可证服务响应失败: {error}"))?;
if text.trim().is_empty() {
return Err(format!("许可证服务返回空响应 ({status})"));
}
serde_json::from_str(&text).map_err(|error| {
format!(
"许可证服务响应无法解析 ({status}): {error}; body={}",
&text[..text.len().min(200)]
)
})
}
struct RemoteLicenseInfo {
expires_at: Option<u64>,
features: Vec<String>,
max_devices: Option<u32>,
plan_type: Option<String>,
}
fn parse_remote_info(data: &Value) -> RemoteLicenseInfo {
let days_left = json_i64_field(data, "days_left", "daysLeft");
let expires_at = expires_from_days_left(days_left);
let features = json_string_array(data, "features", "features");
let max_devices = json_i64_field(data, "max_devices", "maxDevices").map(|n| n.max(0) as u32);
let plan_type = json_string_field(data, "plan_type", "planType");
RemoteLicenseInfo {
expires_at,
features,
max_devices,
plan_type,
}
}
fn remote_activate(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
"device_name": "OpsXShell",
});
let value = post_json("/v1/license/activate", body)?;
if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "激活失败".to_string()));
}
}
Ok(parse_remote_info(envelope_data(&value)))
}
fn remote_verify(license_key: &str, device_id: &str) -> Result<RemoteLicenseInfo, String> {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
});
let value = post_json("/v1/license/verify", body)?;
// Bare object or envelope.
let payload = if value.get("valid").is_some() {
&value
} else if envelope_code(&value) == Some(0) {
envelope_data(&value)
} else if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "验证失败".to_string()));
}
envelope_data(&value)
} else {
&value
};
let valid = payload
.get("valid")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !valid {
return Err(envelope_message(payload)
.or_else(|| json_string_field(payload, "message", "message"))
.unwrap_or_else(|| "许可证无效".to_string()));
}
Ok(parse_remote_info(payload))
}
fn remote_deactivate(license_key: &str, device_id: &str) {
let body = serde_json::json!({
"license_key": license_key,
"device_id": device_id,
});
let _ = post_json("/v1/license/deactivate", body);
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckoutResult {
pub order_no: String,
pub checkout_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
/// Create a pending order via opsx-licence and return the Waffo hosted checkout URL. // Logan.Li
pub fn checkout(
app: &AppHandle,
customer_email: &str,
plan_type: &str,
return_url: Option<&str>,
) -> Result<CheckoutResult, String> {
let email = customer_email.trim();
if email.is_empty() || !email.contains('@') {
return Err("请先填写有效邮箱".to_string());
}
let plan = match plan_type.trim() {
"yearly" | "perpetual" => plan_type.trim(),
other if !other.is_empty() => other,
_ => "perpetual",
};
let device_id = get_or_create_device_id(app)?;
let body = serde_json::json!({
"customer_email": email,
"product_code": "opsxshell",
"plan_type": plan,
"device_id": device_id,
"return_url": return_url.unwrap_or("https://l123.91ai.io/api/v1/purchase/success"),
});
let value = post_json("/v1/checkout", body)?;
if let Some(code) = envelope_code(&value) {
if code != 0 {
return Err(envelope_message(&value).unwrap_or_else(|| "创建结账订单失败".to_string()));
}
}
let data = envelope_data(&value);
let checkout_url = json_string_field(data, "checkout_url", "checkoutUrl")
.ok_or_else(|| "结账链接缺失".to_string())?;
let order_no = json_string_field(data, "order_no", "orderNo").unwrap_or_default();
Ok(CheckoutResult {
order_no,
checkout_url,
amount: data
.get("amount")
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|n| n as f64))),
currency: json_string_field(data, "currency", "currency"),
status: json_string_field(data, "status", "status"),
})
}
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
if !path.exists() {
return Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
});
}
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
match verify_token(&token, &hmac_secret()) {
Ok(payload) => {
if payload.device_id != "*" && payload.device_id != device_id {
return Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
});
}
Ok(LicenseStatus {
plan: payload.plan,
device_id,
activated: true,
expires_at: payload.exp,
features: payload.features,
token: Some(token.trim().to_string()),
})
}
Err(_) => Ok(LicenseStatus {
plan: "free".to_string(),
device_id,
activated: false,
expires_at: None,
features: vec![],
token: None,
}),
match read_stored(&path)? {
None => Ok(free_status(device_id, None)),
Some(stored) => Ok(status_from_stored(device_id, &stored)),
}
}
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
pub fn activate(
app: &AppHandle,
license_key: &str,
email: Option<&str>,
) -> Result<LicenseStatus, String> {
reject_legacy_key(license_key)?;
let device_id = get_or_create_device_id(app)?;
let payload = verify_token(token, &hmac_secret())?;
if payload.device_id != "*" && payload.device_id != device_id {
return Err("许可证与当前设备不匹配".to_string());
let key = license_key.trim();
if is_offline_license_key(key) {
let claims = verify_offline_serial(key, &device_id)?;
let email_store = email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or(claims.email.clone());
let stored = StoredLicense {
license_key: key.to_string(),
email: email_store,
plan: "pro".to_string(),
activated: true,
expires_at: Some(claims.exp as u64),
features: vec!["*".to_string()],
max_devices: Some(claims.max_devices.max(1) as u32),
plan_type: Some(claims.plan_type),
last_verified_at: now_secs(),
};
let path = license_path(app)?;
write_stored(&path, &stored)?;
return read_status(app);
}
let mut info = remote_activate(key, &device_id)?;
if let Ok(verify) = remote_verify(key, &device_id) {
if verify.expires_at.is_some() {
info.expires_at = verify.expires_at;
}
if !verify.features.is_empty() {
info.features = verify.features;
}
if verify.max_devices.is_some() {
info.max_devices = verify.max_devices;
}
if verify.plan_type.is_some() {
info.plan_type = verify.plan_type;
}
}
if info.features.is_empty() {
info.features = vec!["*".to_string()];
}
let stored = StoredLicense {
license_key: key.to_string(),
email: email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
plan: "pro".to_string(),
activated: true,
expires_at: info.expires_at,
features: info.features,
max_devices: info.max_devices,
plan_type: info.plan_type,
last_verified_at: now_secs(),
};
let path = license_path(app)?;
ensure_parent(&path)?;
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
write_stored(&path, &stored)?;
read_status(app)
}
pub fn set_email(app: &AppHandle, email: &str) -> Result<LicenseStatus, String> {
let path = license_path(app)?;
let mut stored = read_stored(&path)?.unwrap_or(StoredLicense {
license_key: String::new(),
email: None,
plan: "free".to_string(),
activated: false,
expires_at: None,
features: vec![],
max_devices: None,
plan_type: None,
last_verified_at: 0,
});
let trimmed = email.trim();
stored.email = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
write_stored(&path, &stored)?;
read_status(app)
}
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
let device_id = get_or_create_device_id(app)?;
let path = license_path(app)?;
if let Ok(Some(stored)) = read_stored(&path) {
if !stored.license_key.is_empty() && !is_offline_license_key(&stored.license_key) {
remote_deactivate(&stored.license_key, &device_id);
}
}
if path.exists() {
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
}
@@ -202,16 +646,68 @@ mod tests {
use super::*;
#[test]
fn roundtrip_token() {
let payload = LicensePayload {
fn normalize_api_base_accepts_host_and_strips_v1() {
assert_eq!(
normalize_api_base("https://l123.91ai.io"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("https://l123.91ai.io/api/"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("https://l123.91ai.io/api/v1"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("http://l123.91ai.io/api"),
"https://l123.91ai.io/api"
);
assert_eq!(
normalize_api_base("http://127.0.0.1:9001/api"),
"http://127.0.0.1:9001/api"
);
}
#[test]
fn rejects_opsx1_and_private_key() {
assert!(reject_legacy_key("opsx1.abc.def").is_err());
assert!(reject_legacy_key("MIIEowIBAAK...").is_err());
assert!(reject_legacy_key("LIC-TEST-KEY").is_ok());
assert!(reject_legacy_key("LICOFF-1.abc.def").is_ok());
}
#[test]
fn detects_offline_prefix() {
assert!(is_offline_license_key("LICOFF-1.x.y"));
assert!(!is_offline_license_key("LIC-2026-AAAA"));
}
#[test]
fn stored_license_roundtrip_json() {
let stored = StoredLicense {
license_key: "LIC-ABC".to_string(),
email: Some("a@b.com".to_string()),
plan: "pro".to_string(),
device_id: "dev-1".to_string(),
iat: 1,
exp: None,
activated: true,
expires_at: None,
features: vec!["*".to_string()],
max_devices: Some(3),
plan_type: Some("perpetual".to_string()),
last_verified_at: 1,
};
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
assert_eq!(parsed.device_id, "dev-1");
let json = serde_json::to_string(&stored).unwrap();
let parsed: StoredLicense = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.license_key, "LIC-ABC");
assert_eq!(parsed.email.as_deref(), Some("a@b.com"));
assert!(parsed.activated);
assert_eq!(parsed.max_devices, Some(3));
assert_eq!(parsed.plan_type.as_deref(), Some("perpetual"));
}
#[test]
fn expires_from_days_left_perpetual() {
assert_eq!(expires_from_days_left(Some(40_000)), None);
assert!(expires_from_days_left(Some(30)).is_some());
}
}
+147
View File
@@ -99,6 +99,7 @@ impl LocalTerminalService {
.map_err(|error| format!("failed to open local PTY: {error}"))?;
let mut command = CommandBuilder::new(&shell);
command.cwd(cwd);
apply_login_shell_args(&mut command, &shell);
apply_terminal_env(&mut command);
let child = pair
.slave
@@ -216,8 +217,27 @@ fn local_home_dir() -> Option<PathBuf> {
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
}
/// Match Terminal.app / VS Code so a Finder/Dock-launched GUI process still
/// sees Homebrew, `/usr/local/bin` (Ollama), and other login-profile tools.
fn apply_login_shell_args(command: &mut CommandBuilder, shell: &str) {
for arg in login_shell_args(&shell_kind(shell)) {
command.arg(arg);
}
}
fn login_shell_args(kind: &str) -> &'static [&'static str] {
match kind {
"zsh" | "bash" | "fish" | "sh" => &["-l"],
_ => &[],
}
}
/// Match VS Code integrated terminal env so local shells (and nested `ssh`)
/// get a color-capable, UTF-8 locale. See vscode `addTerminalEnvironmentKeys`.
///
/// macOS GUI apps inherit a stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`).
/// VS Code repairs this by resolving a login-shell env; we do the same by
/// starting a login shell and prepending the dirs `path_helper` would add.
fn apply_terminal_env(command: &mut CommandBuilder) {
#[cfg(windows)]
{
@@ -237,9 +257,100 @@ fn apply_terminal_env(command: &mut CommandBuilder) {
if should_set_utf8_lang() {
command.env("LANG", utf8_lang());
}
let current = env::var("PATH").unwrap_or_default();
let enriched = enrich_inherited_path(&current);
if enriched != current {
command.env("PATH", enriched);
}
}
}
fn path_separator() -> char {
if cfg!(windows) {
';'
} else {
':'
}
}
/// Prepend missing user/system bin dirs onto a GUI-minimal PATH.
/// Existing entries keep their order; duplicates are not added.
fn merge_path(current: &str, extras: &[String]) -> String {
let sep = path_separator();
let existing: Vec<&str> = current.split(sep).filter(|part| !part.is_empty()).collect();
let mut seen: HashSet<&str> = existing.iter().copied().collect();
let mut prefix = Vec::new();
for dir in extras {
if dir.is_empty() || !seen.insert(dir.as_str()) {
continue;
}
prefix.push(dir.as_str());
}
prefix
.into_iter()
.chain(existing)
.collect::<Vec<_>>()
.join(&sep.to_string())
}
#[cfg(not(windows))]
fn extra_path_dirs() -> Vec<String> {
let mut dirs = Vec::new();
if let Some(home) = local_home_dir() {
for rel in [".local/bin", ".cargo/bin", "bin"] {
dirs.push(home.join(rel).to_string_lossy().into_owned());
}
}
#[cfg(target_os = "macos")]
{
dirs.extend([
"/opt/homebrew/bin".into(),
"/opt/homebrew/sbin".into(),
"/usr/local/bin".into(),
"/usr/local/sbin".into(),
]);
dirs.extend(macos_path_helper_dirs());
}
#[cfg(all(unix, not(target_os = "macos")))]
{
dirs.extend(["/usr/local/bin".into(), "/usr/local/sbin".into()]);
}
dirs
}
#[cfg(target_os = "macos")]
fn macos_path_helper_dirs() -> Vec<String> {
let mut dirs = read_path_file(Path::new("/etc/paths"));
if let Ok(entries) = std::fs::read_dir("/etc/paths.d") {
let mut files: Vec<_> = entries.filter_map(|entry| entry.ok()).collect();
files.sort_by_key(|entry| entry.file_name());
for file in files {
dirs.extend(read_path_file(&file.path()));
}
}
dirs
}
#[cfg(target_os = "macos")]
fn read_path_file(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect()
}
#[cfg(not(windows))]
fn enrich_inherited_path(current: &str) -> String {
let extras: Vec<String> = extra_path_dirs()
.into_iter()
.filter(|dir| Path::new(dir).is_dir())
.collect();
merge_path(current, &extras)
}
fn should_set_utf8_lang() -> bool {
match env::var("LANG") {
Ok(lang) => {
@@ -449,4 +560,40 @@ mod tests {
assert!(!shell.is_empty());
}
}
#[test]
fn unix_shells_start_as_login_shells() {
assert_eq!(login_shell_args("zsh"), &["-l"]);
assert_eq!(login_shell_args("bash"), &["-l"]);
assert_eq!(login_shell_args("fish"), &["-l"]);
assert_eq!(login_shell_args("sh"), &["-l"]);
assert!(login_shell_args("pwsh").is_empty());
assert!(login_shell_args("cmd").is_empty());
}
#[test]
fn merge_path_prepends_missing_dirs_without_duplicates() {
let merged = merge_path(
"/usr/bin:/bin:/usr/sbin:/sbin",
&[
"/usr/local/bin".into(),
"/opt/homebrew/bin".into(),
"/usr/bin".into(),
],
);
assert_eq!(
merged,
"/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
);
}
#[cfg(target_os = "macos")]
#[test]
fn enriches_gui_path_with_usr_local_bin() {
let enriched = enrich_inherited_path("/usr/bin:/bin:/usr/sbin:/sbin");
assert!(
enriched.split(':').any(|part| part == "/usr/local/bin"),
"expected /usr/local/bin in {enriched}"
);
}
}
+87 -24
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, Read, Write},
net::{Shutdown, TcpStream},
sync::{
@@ -19,7 +19,7 @@ use crate::domain::ssh::{
ShellDataEvent, SshConnectRequest,
};
use super::tunnel::TunnelManager;
use super::tunnel::{connection_fingerprint, TunnelManager};
mod auth;
mod host_metrics;
@@ -50,13 +50,27 @@ struct Shell {
pending_output: Arc<Mutex<Vec<u8>>>,
}
/// Dedicated SSH transport + persistent SFTP channel for one workload.
/// Each UI SFTP tab owns a browsing channel and may lazily open a separate
/// transfer channel; neither shares the pooled session used by exec/keepalive.
struct ManagedSftp {
connection_id: String,
session: Session,
sftp: ssh2::Sftp,
}
#[derive(Default)]
pub struct SshService {
connections: Mutex<HashMap<String, Connection>>,
shells: Mutex<HashMap<String, Shell>>,
sftps: Mutex<HashMap<String, String>>,
/// Interactive browsing channel for each SFTP tab.
sftps: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
/// Lazily-created transfer channel, isolated from browsing/file operations.
sftp_transfers: Mutex<HashMap<String, Arc<Mutex<ManagedSftp>>>>,
/// Per-connection cancel flag for in-flight `ssh_exec` (Agent 停止).
active_execs: Mutex<HashMap<String, Arc<AtomicBool>>>,
/// Per-SFTP-session cancel for the active upload/download copy loop.
sftp_transfer_cancels: Mutex<HashMap<String, Arc<AtomicBool>>>,
tunnels: TunnelManager,
}
@@ -65,15 +79,30 @@ impl SshService {
validate_config(&config)?;
let session = establish_session(&config)?;
let id = Uuid::new_v4().to_string();
self.connections
.lock()
.map_err(lock_error)?
.insert(id.clone(), Connection { session, config });
let fingerprint = connection_fingerprint(&config);
let live = {
let mut connections = self.connections.lock().map_err(lock_error)?;
connections.insert(id.clone(), Connection { session, config });
connections.keys().cloned().collect::<HashSet<_>>()
};
// Reattach listeners that survived a reconnect (same SSH identity,
// previous connection id is gone). Runtime is never persisted.
self.tunnels.adopt_orphans(&id, &fingerprint, &live)?;
Ok(id)
}
pub fn disconnect(&self, app: &AppHandle, connection_id: &str) -> Result<(), String> {
self.tunnels.stop_connection(connection_id)?;
pub fn disconnect(
&self,
app: &AppHandle,
connection_id: &str,
retain_forwards: bool,
) -> Result<(), String> {
// Dedicated tunnel sessions keep listening across terminal flaps when
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
// must still close real Listeners.
if !retain_forwards {
let _ = self.tunnels.stop_connection(connection_id);
}
if let Some(flag) = self
.active_execs
.lock()
@@ -97,10 +126,22 @@ impl SshService {
let _ = shell.session.disconnect(None, "closed by user", None);
}
}
self.sftps
.lock()
.map_err(lock_error)?
.retain(|_, id| id != connection_id);
let sftp_ids = {
let sftps = self.sftps.lock().map_err(lock_error)?;
sftps
.iter()
.filter_map(|(id, handle)| {
handle
.lock()
.ok()
.filter(|sftp| sftp.connection_id == connection_id)
.map(|_| id.clone())
})
.collect::<Vec<_>>()
};
for sftp_id in sftp_ids {
let _ = self.close_sftp(&sftp_id);
}
if let Some(connection) = self
.connections
.lock()
@@ -256,8 +297,8 @@ impl SshService {
.map(|_| ())
.map_err(|error| format!("SSH 保活失败: {error}"))?;
// Interactive panes use dedicated SSH sessions so a busy SFTP/exec
// channel cannot stall terminal IO. Keep those transports alive too.
// Interactive panes / SFTP use dedicated SSH sessions so a busy
// SFTP/exec channel cannot stall terminal IO. Keep those alive too.
let shell_sessions = self
.shells
.lock()
@@ -271,6 +312,21 @@ impl SshService {
.keepalive_send()
.map_err(|error| format!("Shell SSH 保活失败: {error}"))?;
}
let sftp_sessions = {
let sftps = self.sftps.lock().map_err(lock_error)?;
sftps
.values()
.filter_map(|handle| {
let guard = handle.lock().ok()?;
(guard.connection_id == connection_id).then(|| guard.session.clone())
})
.collect::<Vec<_>>()
};
for session in sftp_sessions {
session
.keepalive_send()
.map_err(|error| format!("SFTP SSH 保活失败: {error}"))?;
}
Ok(())
}
@@ -393,6 +449,7 @@ impl SshService {
&self,
connection_id: &str,
forward_id: Option<String>,
local_bind_host: String,
local_port: u16,
remote_host: String,
remote_port: u16,
@@ -402,6 +459,7 @@ impl SshService {
forward_id,
connection_id,
config,
local_bind_host,
local_port,
remote_host,
remote_port,
@@ -423,6 +481,7 @@ impl SshService {
&self,
connection_id: &str,
forward_id: Option<String>,
remote_bind_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
@@ -432,6 +491,7 @@ impl SshService {
forward_id,
connection_id,
config,
remote_bind_host,
remote_port,
local_host,
local_port,
@@ -481,7 +541,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
session
.handshake()
.map_err(|error| format!("SSH 握手失败 ({address}): {error}"))?;
session.set_keepalive(config.keep_alive.unwrap_or(true), 30);
session.set_keepalive(config.keep_alive.unwrap_or(true), 15);
verify_host_key(&session, &config.host, config.port)?;
authenticate(&session, config)?;
Ok(session)
@@ -782,7 +842,10 @@ mod tests {
target.seek(SeekFrom::Start(3)).unwrap();
let mut progress = Vec::new();
copy_with_progress(&mut source, &mut target, 3, 6, |value| progress.push(value)).unwrap();
copy_with_progress(&mut source, &mut target, 3, 6, None, |value| {
progress.push(value)
})
.unwrap();
assert_eq!(target.into_inner(), b"abcdef");
assert_eq!(progress.first(), Some(&3));
@@ -835,23 +898,23 @@ mod tests {
let remote_path = format!("/tmp/opsxshell-sftp-{probe_id}.txt");
let payload = format!("OPSX_SFTP_ROUNDTRIP_{probe_id}\n").into_bytes();
fs::write(&local_source, &payload).expect("write local SFTP fixture");
let sftp = service.sftp(&sftp_id).expect("SFTP channel");
let transfer_result = (|| -> Result<(), String> {
upload_file(&sftp, &local_source, &remote_path, |_, _| {})?;
download_file(&sftp, &remote_path, &local_download, |_, _| {})?;
let transfer_result = service.with_sftp(&sftp_id, |sftp| {
upload_file(sftp, &local_source, &remote_path, None, |_, _| {})?;
download_file(sftp, &remote_path, &local_download, None, |_, _| {})?;
let downloaded = fs::read(&local_download)
.map_err(|error| format!("read downloaded probe: {error}"))?;
if downloaded != payload {
return Err("SFTP round-trip content mismatch".to_string());
}
let _ = sftp.unlink(Path::new(&remote_path));
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
Ok(())
})();
let _ = sftp.unlink(Path::new(&remote_path));
let _ = sftp.unlink(Path::new(&format!("{remote_path}.opsx-part")));
});
let _ = fs::remove_file(&local_source);
let _ = fs::remove_file(&local_download);
let _ = fs::remove_file(local_partial_path(&local_download));
transfer_result.expect("SFTP upload/download round trip");
service.close_sftp(&sftp_id).expect("close SFTP");
let connection = service.connection(&connection_id).unwrap();
let shell_session = establish_session(&connection.config).expect("shell SSH session");
let mut shell = shell_session.channel_session().expect("shell channel");
+1 -1
View File
@@ -264,7 +264,7 @@ fn remove_known_host_entries(host: &str, port: u16) -> Result<(), String> {
let retained = input
.lines()
.filter(|line| {
line.split_whitespace().next().map_or(true, |hosts| {
line.split_whitespace().next().is_none_or(|hosts| {
!hosts
.split(',')
.any(|candidate| candidate == label || (port == 22 && candidate == host))
+486 -115
View File
@@ -2,6 +2,11 @@ use std::{
fs::{self, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use ssh2::{FileStat, OpenFlags, OpenType, RenameFlags};
@@ -10,39 +15,103 @@ use uuid::Uuid;
use crate::domain::ssh::{SftpDirEntry, SftpFileAttrs, SftpProgressEvent};
use super::{lock_error, path::expand_home, SshService};
use super::{establish_session, lock_error, path::expand_home, ManagedSftp, SshService};
/// Soft cap for in-memory remote text editing (matches UI copy).
const MAX_REMOTE_TEXT_BYTES: u64 = 2 * 1024 * 1024;
const TEXT_READ_CHUNK: usize = 64 * 1024;
impl SshService {
pub fn create_sftp(&self, connection_id: &str) -> Result<String, String> {
let connection = self.connection(connection_id)?;
connection
.session
// Keep browsing off the pooled connection. Upload/download lazily create
// their own transport so a long copy cannot block directory operations.
let session = establish_session(&connection.config)?;
let sftp = session
.sftp()
.map_err(|error| format!("初始化 SFTP 失败: {error}"))?;
let id = format!("sftp-{}", Uuid::new_v4());
self.sftps
.lock()
.map_err(lock_error)?
.insert(id.clone(), connection_id.to_string());
self.sftps.lock().map_err(lock_error)?.insert(
id.clone(),
Arc::new(Mutex::new(ManagedSftp {
connection_id: connection_id.to_string(),
session,
sftp,
})),
);
Ok(id)
}
pub fn close_sftp(&self, sftp_id: &str) -> Result<(), String> {
let _ = self.cancel_sftp_transfer(sftp_id);
let _ = self
.sftp_transfer_cancels
.lock()
.map_err(lock_error)?
.remove(sftp_id);
let browse = self.sftps.lock().map_err(lock_error)?.remove(sftp_id);
let transfer = self
.sftp_transfers
.lock()
.map_err(lock_error)?
.remove(sftp_id);
disconnect_managed_sftp(browse, "sftp browsing closed");
disconnect_managed_sftp(transfer, "sftp transfer closed");
Ok(())
}
/// Signal the in-flight copy loop for this SFTP session to stop at the next chunk.
pub fn cancel_sftp_transfer(&self, sftp_id: &str) -> Result<(), String> {
tracing::info!(sftp_id = %sftp_id, "SFTP transfer cancellation requested");
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
let flag = cancels
.entry(sftp_id.to_string())
.or_insert_with(|| Arc::new(AtomicBool::new(true)));
flag.store(true, Ordering::SeqCst);
Ok(())
}
fn begin_sftp_transfer(&self, sftp_id: &str) -> Result<Arc<AtomicBool>, String> {
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
Ok(Arc::clone(
cancels
.entry(sftp_id.to_string())
.or_insert_with(|| Arc::new(AtomicBool::new(false))),
))
}
fn finish_sftp_transfer(
&self,
sftp_id: &str,
completed_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
let mut cancels = self.sftp_transfer_cancels.lock().map_err(lock_error)?;
if cancels
.get(sftp_id)
.is_some_and(|current| Arc::ptr_eq(current, completed_flag))
{
cancels.remove(sftp_id);
}
Ok(())
}
pub fn sftp_list(&self, sftp_id: &str, remote_path: &str) -> Result<Vec<SftpDirEntry>, String> {
let sftp = self.sftp(sftp_id)?;
let mut entries = sftp
.readdir(Path::new(remote_path))
.map_err(|error| format!("读取远程目录失败: {error}"))?
.into_iter()
.filter_map(|(path, stat)| {
let filename = path.file_name()?.to_string_lossy().to_string();
if filename == "." || filename == ".." {
return None;
}
Some(dir_entry(filename, stat))
})
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.filename.to_lowercase());
Ok(entries)
self.with_browse_sftp(sftp_id, |sftp| {
let mut entries = sftp
.readdir(Path::new(remote_path))
.map_err(|error| format!("读取远程目录失败: {error}"))?
.into_iter()
.filter_map(|(path, stat)| {
let filename = path.file_name()?.to_string_lossy().to_string();
if filename == "." || filename == ".." {
return None;
}
Some(dir_entry(filename, stat))
})
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.filename.to_lowercase());
Ok(entries)
})
}
pub fn sftp_upload(
@@ -52,13 +121,21 @@ impl SshService {
local_path: &str,
remote_path: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
upload_file(
&sftp,
&expand_home(local_path),
remote_path,
|transferred, total| emit_progress(app, sftp_id, transferred, total),
)
tracing::info!(sftp_id = %sftp_id, operation = "upload", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
upload_file(
sftp,
&expand_home(local_path),
remote_path,
Some(cancel.as_ref()),
|transferred, total| progress.emit(transferred, total),
)
});
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
trace_transfer_result(sftp_id, "upload", &result);
result
}
pub fn sftp_download(
@@ -68,11 +145,22 @@ impl SshService {
remote_path: &str,
local_path: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let local_path = expand_home(local_path);
download_file(&sftp, remote_path, &local_path, |transferred, total| {
emit_progress(app, sftp_id, transferred, total)
})
tracing::info!(sftp_id = %sftp_id, operation = "download", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
let local_path = expand_home(local_path);
download_file(
sftp,
remote_path,
&local_path,
Some(cancel.as_ref()),
|transferred, total| progress.emit(transferred, total),
)
});
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
trace_transfer_result(sftp_id, "download", &result);
result
}
pub fn sftp_upload_dir(
@@ -82,13 +170,21 @@ impl SshService {
local_dir: &str,
remote_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
upload_dir(
&sftp,
&expand_home(local_dir),
remote_dir,
&mut |transferred, total| emit_progress(app, sftp_id, transferred, total),
)
tracing::info!(sftp_id = %sftp_id, operation = "upload_dir", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
upload_dir(
sftp,
&expand_home(local_dir),
remote_dir,
Some(cancel.as_ref()),
&mut |transferred, total| progress.emit(transferred, total),
)
});
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
trace_transfer_result(sftp_id, "upload_dir", &result);
result
}
pub fn sftp_download_dir(
@@ -98,11 +194,22 @@ impl SshService {
remote_dir: &str,
local_dir: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let local_dir = expand_home(local_dir);
download_dir(&sftp, remote_dir, &local_dir, &mut |transferred, total| {
emit_progress(app, sftp_id, transferred, total)
})
tracing::info!(sftp_id = %sftp_id, operation = "download_dir", "SFTP transfer started");
let cancel = self.begin_sftp_transfer(sftp_id)?;
let mut progress = ProgressEmitter::new(app.clone(), sftp_id);
let result = self.with_transfer_sftp(sftp_id, |sftp| {
let local_dir = expand_home(local_dir);
download_dir(
sftp,
remote_dir,
&local_dir,
Some(cancel.as_ref()),
&mut |transferred, total| progress.emit(transferred, total),
)
});
let _ = self.finish_sftp_transfer(sftp_id, &cancel);
trace_transfer_result(sftp_id, "download_dir", &result);
result
}
pub fn sftp_rename(
@@ -111,9 +218,10 @@ impl SshService {
old_remote_path: &str,
new_remote_path: &str,
) -> Result<(), String> {
self.sftp(sftp_id)?
.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
.map_err(|error| format!("远程重命名失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.rename(Path::new(old_remote_path), Path::new(new_remote_path), None)
.map_err(|error| format!("远程重命名失败: {error}"))
})
}
pub fn sftp_delete(
@@ -122,31 +230,34 @@ impl SshService {
remote_path: &str,
directory: bool,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
if directory {
sftp.rmdir(Path::new(remote_path))
.map_err(|error| format!("删除远程目录失败: {error}"))
} else {
sftp.unlink(Path::new(remote_path))
.map_err(|error| format!("删除远程文件失败: {error}"))
}
self.with_browse_sftp(sftp_id, |sftp| {
if directory {
sftp.rmdir(Path::new(remote_path))
.map_err(|error| format!("删除远程目录失败: {error}"))
} else {
sftp.unlink(Path::new(remote_path))
.map_err(|error| format!("删除远程文件失败: {error}"))
}
})
}
pub fn sftp_mkdir(&self, sftp_id: &str, remote_path: &str) -> Result<(), String> {
self.sftp(sftp_id)?
.mkdir(Path::new(remote_path), 0o755)
.map_err(|error| format!("创建远程目录失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.mkdir(Path::new(remote_path), 0o755)
.map_err(|error| format!("创建远程目录失败: {error}"))
})
}
pub fn sftp_stat(&self, sftp_id: &str, remote_path: &str) -> Result<FileStat, String> {
self.sftp(sftp_id)?
.lstat(Path::new(remote_path))
.map_err(|error| format!("读取远程属性失败: {error}"))
self.with_browse_sftp(sftp_id, |sftp| {
sftp.lstat(Path::new(remote_path))
.map_err(|error| format!("读取远程属性失败: {error}"))
})
}
pub fn sftp_chmod(&self, sftp_id: &str, remote_path: &str, mode: u32) -> Result<(), String> {
self.sftp(sftp_id)?
.setstat(
self.with_browse_sftp(sftp_id, |sftp| {
sftp.setstat(
Path::new(remote_path),
FileStat {
size: None,
@@ -158,6 +269,7 @@ impl SshService {
},
)
.map_err(|error| format!("修改远程权限失败: {error}"))
})
}
pub fn sftp_read_text(
@@ -165,15 +277,9 @@ impl SshService {
sftp_id: &str,
remote_path: &str,
) -> Result<(String, u64), String> {
let sftp = self.sftp(sftp_id)?;
let mut file = sftp
.open(Path::new(remote_path))
.map_err(|error| format!("打开远程文件失败: {error}"))?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|error| format!("远程文件不是有效 UTF-8 文本: {error}"))?;
let size = content.len() as u64;
Ok((content, size))
self.with_browse_sftp(sftp_id, |sftp| {
read_remote_text_capped(sftp, Path::new(remote_path), MAX_REMOTE_TEXT_BYTES)
})
}
pub fn sftp_write_text(
@@ -182,26 +288,199 @@ impl SshService {
remote_path: &str,
content: &str,
) -> Result<(), String> {
let sftp = self.sftp(sftp_id)?;
let mut file = sftp
.create(Path::new(remote_path))
.map_err(|error| format!("创建远程文件失败: {error}"))?;
file.write_all(content.as_bytes())
.map_err(|error| format!("写入远程文件失败: {error}"))
if content.len() as u64 > MAX_REMOTE_TEXT_BYTES {
return Err(format!(
"文件过大({}),编辑上限约 2MB",
format_byte_size(content.len() as u64)
));
}
self.with_browse_sftp(sftp_id, |sftp| {
let mut file = sftp
.create(Path::new(remote_path))
.map_err(|error| format!("创建远程文件失败: {error}"))?;
file.write_all(content.as_bytes())
.map_err(|error| format!("写入远程文件失败: {error}"))
})
}
pub(super) fn sftp(&self, sftp_id: &str) -> Result<ssh2::Sftp, String> {
let connection_id = self
/// Blocking access to the browsing channel for native integration tests.
#[cfg(test)]
pub(super) fn with_sftp<R>(
&self,
sftp_id: &str,
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
) -> Result<R, String> {
let handle = self
.sftps
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned()
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
self.connection(&connection_id)?
.session
.sftp()
.map_err(|error| format!("创建 SFTP 通道失败: {error}"))
let guard = handle.lock().map_err(lock_error)?;
f(&guard.sftp)
}
/// Transfer-only channel. It is opened lazily and then reused by the serial
/// transfer pump, so large copies never hold the browsing channel lock.
fn with_transfer_sftp<R>(
&self,
sftp_id: &str,
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
) -> Result<R, String> {
let existing = self
.sftp_transfers
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned();
let handle = match existing {
Some(handle) => handle,
None => {
let browse = self
.sftps
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned()
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
let connection_id = browse.lock().map_err(lock_error)?.connection_id.clone();
let config = self.connection(&connection_id)?.config;
let session = establish_session(&config)?;
let sftp = session
.sftp()
.map_err(|error| format!("初始化 SFTP 传输通道失败: {error}"))?;
let created = Arc::new(Mutex::new(ManagedSftp {
connection_id,
session,
sftp,
}));
tracing::info!(sftp_id = %sftp_id, "SFTP transfer channel created");
self.sftp_transfers
.lock()
.map_err(lock_error)?
.entry(sftp_id.to_string())
.or_insert_with(|| Arc::clone(&created))
.clone()
}
};
let guard = handle.lock().map_err(lock_error)?;
f(&guard.sftp)
}
/// Serialize short browsing/file operations on their dedicated background channel.
/// Tauri commands call this from `spawn_blocking`, so waiting here never blocks the UI.
pub(super) fn with_browse_sftp<R>(
&self,
sftp_id: &str,
f: impl FnOnce(&ssh2::Sftp) -> Result<R, String>,
) -> Result<R, String> {
let handle = self
.sftps
.lock()
.map_err(lock_error)?
.get(sftp_id)
.cloned()
.ok_or_else(|| format!("SFTP 会话不存在: {sftp_id}"))?;
let guard = handle.lock().map_err(lock_error)?;
f(&guard.sftp)
}
}
fn disconnect_managed_sftp(handle: Option<Arc<Mutex<ManagedSftp>>>, reason: &str) {
if let Some(handle) = handle {
if let Ok(guard) = handle.lock() {
let _ = guard.session.disconnect(None, reason, None);
}
}
}
fn format_byte_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
let value = bytes as f64;
if value >= MB {
format!("{:.1} MB", value / MB)
} else if value >= KB {
format!("{:.1} KB", value / KB)
} else {
format!("{bytes} B")
}
}
fn looks_like_binary(sample: &[u8]) -> bool {
if sample.is_empty() {
return false;
}
// NUL in the first chunk is a strong binary signal for text editors.
sample.contains(&0)
}
fn read_remote_text_capped(
sftp: &ssh2::Sftp,
remote_path: &Path,
max_bytes: u64,
) -> Result<(String, u64), String> {
if let Ok(stat) = sftp.stat(remote_path) {
if let Some(size) = stat.size {
if size > max_bytes {
return Err(format!(
"文件过大({}),编辑上限约 2MB",
format_byte_size(size)
));
}
}
}
let mut file = sftp
.open(remote_path)
.map_err(|error| format!("打开远程文件失败: {error}"))?;
let mut buffer = Vec::new();
let mut chunk = vec![0u8; TEXT_READ_CHUNK];
let mut sniffed = false;
loop {
let read = file
.read(&mut chunk)
.map_err(|error| format!("读取远程文件失败: {error}"))?;
if read == 0 {
break;
}
if !sniffed {
sniffed = true;
if looks_like_binary(&chunk[..read]) {
return Err("该文件像是二进制内容,无法用文本编辑器打开".to_string());
}
}
if (buffer.len() as u64).saturating_add(read as u64) > max_bytes {
return Err(format!(
"文件过大(超过 {}),编辑上限约 2MB",
format_byte_size(max_bytes)
));
}
buffer.extend_from_slice(&chunk[..read]);
}
let size = buffer.len() as u64;
let content =
String::from_utf8(buffer).map_err(|_| "远程文件不是有效 UTF-8 文本".to_string())?;
Ok((content, size))
}
fn trace_transfer_result(sftp_id: &str, operation: &str, result: &Result<(), String>) {
match result {
Ok(()) => tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer completed"),
Err(error) if error.contains("传输已取消") => {
tracing::info!(sftp_id = %sftp_id, operation, "SFTP transfer canceled")
}
Err(error) => tracing::warn!(
sftp_id = %sftp_id,
operation,
error = %error,
"SFTP transfer failed"
),
}
}
@@ -235,12 +514,28 @@ fn permission_string(mode: u32) -> String {
output
}
fn transfer_cancelled(cancel: Option<&AtomicBool>) -> bool {
cancel
.map(|flag| flag.load(Ordering::Relaxed))
.unwrap_or(false)
}
fn ensure_not_cancelled(cancel: Option<&AtomicBool>) -> Result<(), String> {
if transfer_cancelled(cancel) {
Err("传输已取消".into())
} else {
Ok(())
}
}
pub(super) fn upload_file(
sftp: &ssh2::Sftp,
local_path: &Path,
remote_path: &str,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let mut source = fs::File::open(local_path)
.map_err(|error| format!("打开本地文件失败 ({}): {error}", local_path.display()))?;
let total = source
@@ -271,9 +566,14 @@ pub(super) fn upload_file(
target
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("定位远程断点失败: {error}"))?;
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
progress(transferred, total)
})?;
copy_with_progress(
&mut source,
&mut target,
offset,
total,
cancel,
|transferred| progress(transferred, total),
)?;
drop(target);
let uploaded = sftp
.stat(Path::new(&partial_path))
@@ -292,8 +592,10 @@ pub(super) fn download_file(
sftp: &ssh2::Sftp,
remote_path: &str,
local_path: &Path,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let mut source = sftp
.open(Path::new(remote_path))
.map_err(|error| format!("打开远程文件失败 ({remote_path}): {error}"))?;
@@ -319,9 +621,14 @@ pub(super) fn download_file(
target
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("定位本地断点失败: {error}"))?;
copy_with_progress(&mut source, &mut target, offset, total, |transferred| {
progress(transferred, total)
})?;
copy_with_progress(
&mut source,
&mut target,
offset,
total,
cancel,
|transferred| progress(transferred, total),
)?;
target
.sync_all()
.map_err(|error| format!("同步本地临时文件失败: {error}"))?;
@@ -397,8 +704,10 @@ fn upload_dir(
sftp: &ssh2::Sftp,
local_dir: &Path,
remote_dir: &str,
cancel: Option<&AtomicBool>,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let meta = fs::metadata(local_dir)
.map_err(|error| format!("读取本地目录失败 ({}): {error}", local_dir.display()))?;
if !meta.is_dir() {
@@ -413,6 +722,7 @@ fn upload_dir(
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
ensure_not_cancelled(cancel)?;
let file_type = entry
.file_type()
.map_err(|error| format!("读取本地文件类型失败: {error}"))?;
@@ -423,11 +733,9 @@ fn upload_dir(
let local_child = entry.path();
let remote_child = join_remote(remote_dir, &name);
if file_type.is_dir() {
upload_dir(sftp, &local_child, &remote_child, progress)?;
upload_dir(sftp, &local_child, &remote_child, cancel, progress)?;
} else if file_type.is_file() {
upload_file(sftp, &local_child, &remote_child, |transferred, total| {
progress(transferred, total)
})?;
upload_file(sftp, &local_child, &remote_child, cancel, &mut *progress)?;
}
// skip symlinks / special files for safety
}
@@ -438,8 +746,10 @@ fn download_dir(
sftp: &ssh2::Sftp,
remote_dir: &str,
local_dir: &Path,
cancel: Option<&AtomicBool>,
progress: &mut dyn FnMut(u64, u64),
) -> Result<(), String> {
ensure_not_cancelled(cancel)?;
let stat = sftp
.lstat(Path::new(remote_dir))
.map_err(|error| format!("读取远程目录失败 ({remote_dir}): {error}"))?;
@@ -455,6 +765,7 @@ fn download_dir(
entries.sort_by_key(|(path, _)| path.file_name().map(|name| name.to_os_string()));
for (path, entry_stat) in entries {
ensure_not_cancelled(cancel)?;
let name = match path.file_name() {
Some(value) => value.to_string_lossy().to_string(),
None => continue,
@@ -465,11 +776,9 @@ fn download_dir(
let remote_child = join_remote(remote_dir, &name);
let local_child = local_dir.join(&name);
if is_dir_mode(entry_stat.perm.unwrap_or(0)) {
download_dir(sftp, &remote_child, &local_child, progress)?;
download_dir(sftp, &remote_child, &local_child, cancel, progress)?;
} else {
download_file(sftp, &remote_child, &local_child, |transferred, total| {
progress(transferred, total)
})?;
download_file(sftp, &remote_child, &local_child, cancel, &mut *progress)?;
}
}
Ok(())
@@ -480,12 +789,15 @@ pub(super) fn copy_with_progress(
writer: &mut impl Write,
initial: u64,
total: u64,
cancel: Option<&AtomicBool>,
mut progress: impl FnMut(u64),
) -> Result<(), String> {
let mut buffer = [0_u8; 64 * 1024];
// 256 KiB chunks: fewer syscalls / progress callbacks on large files.
let mut buffer = [0_u8; 256 * 1024];
let mut transferred = initial;
progress(transferred);
loop {
ensure_not_cancelled(cancel)?;
let length = reader
.read(&mut buffer)
.map_err(|error| format!("读取传输数据失败: {error}"))?;
@@ -535,19 +847,78 @@ pub(super) fn local_partial_path(destination: &Path) -> PathBuf {
PathBuf::from(value)
}
fn emit_progress(app: &AppHandle, sftp_id: &str, transferred: u64, total: u64) {
let percentage = transferred
.saturating_mul(100)
.checked_div(total)
.unwrap_or(100)
.min(100) as u8;
let _ = app.emit(
"sftp:progress",
SftpProgressEvent {
/// Throttle IPC progress so a large file does not flood the WebView/React thread.
struct ProgressEmitter {
app: AppHandle,
sftp_id: String,
last_emit: Instant,
last_percent: u8,
}
impl ProgressEmitter {
fn new(app: AppHandle, sftp_id: &str) -> Self {
Self {
app,
sftp_id: sftp_id.to_string(),
transferred,
total,
percentage,
},
);
// Force first emit after start.
last_emit: Instant::now()
.checked_sub(Duration::from_secs(1))
.unwrap_or_else(Instant::now),
last_percent: 255,
}
}
fn emit(&mut self, transferred: u64, total: u64) {
let percentage = if total == 0 {
100
} else {
transferred
.saturating_mul(100)
.checked_div(total)
.unwrap_or(100)
.min(100) as u8
};
let done = total > 0 && transferred >= total;
// Cap IPC to ~1 Hz (plus start/done). Faster % ticks were freezing the WebView.
if !done && self.last_percent != 255 && self.last_emit.elapsed() < Duration::from_secs(1) {
return;
}
self.last_emit = Instant::now();
self.last_percent = percentage;
if cfg!(debug_assertions) {
let sid = if self.sftp_id.len() > 12 {
&self.sftp_id[self.sftp_id.len() - 12..]
} else {
self.sftp_id.as_str()
};
eprintln!("[sftp-xfer] emit sid=…{sid} {percentage}% {transferred}/{total} bytes");
}
let _ = self.app.emit(
"sftp:progress",
SftpProgressEvent {
sftp_id: self.sftp_id.clone(),
transferred,
total,
percentage,
},
);
}
}
#[cfg(test)]
mod cancellation_tests {
use super::*;
#[test]
fn cancellation_requested_before_worker_start_is_not_lost() {
let service = SshService::default();
service.cancel_sftp_transfer("sftp-test").unwrap();
let flag = service.begin_sftp_transfer("sftp-test").unwrap();
assert!(flag.load(Ordering::Acquire));
service.finish_sftp_transfer("sftp-test", &flag).unwrap();
let next = service.begin_sftp_transfer("sftp-test").unwrap();
assert!(!next.load(Ordering::Acquire));
}
}
+471 -123
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
@@ -7,7 +7,7 @@ use std::{
Arc, Mutex,
},
thread,
time::Duration,
time::{Duration, Instant},
};
use ssh2::ErrorCode;
@@ -19,10 +19,12 @@ use super::ssh::establish_session;
mod model;
mod relay;
mod session;
mod socks5;
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
use relay::relay_channel;
use session::TunnelSession;
use socks5::read_target;
enum IdClaim {
@@ -43,16 +45,18 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
local_bind_host: String,
local_port: u16,
remote_host: String,
remote_port: u16,
) -> Result<ForwardStatus, String> {
validate_target(&remote_host, remote_port)?;
let remote_host = validate_target(&remote_host, remote_port)?;
let bind_host = validate_bind_host(&local_bind_host)?;
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let listener = TcpListener::bind(("127.0.0.1", local_port))
let listener = TcpListener::bind((bind_host.as_str(), local_port))
.map_err(|error| format!("监听本地端口失败: {error}"))?;
listener
.set_nonblocking(true)
@@ -65,7 +69,7 @@ impl TunnelManager {
&forward_id,
connection_id,
"local",
"127.0.0.1",
&bind_host,
bound_port,
Some(remote_host.clone()),
Some(remote_port),
@@ -76,42 +80,25 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((socket, _)) => {
let config = config.clone();
let host = remote_host.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
let Ok(session) = establish_session(&config) else {
return;
};
let Ok(channel) =
session.channel_direct_tcpip(&host, remote_port, None)
else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
thread_running.store(false, Ordering::Release);
run_direct_listener(
listener,
config,
DirectTarget::Fixed {
host: remote_host,
port: remote_port,
},
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -155,60 +142,22 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((mut socket, _)) => {
let config = config.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
let (host, port) = match read_target(&mut socket) {
Ok(target) => target,
Err(error) => {
tracing::warn!("SOCKS5 请求解析失败: {error}");
return;
}
};
if cancel.load(Ordering::Acquire) {
return;
}
let session = match establish_session(&config) {
Ok(session) => session,
Err(error) => {
tracing::warn!("SOCKS5 专用 SSH 连接失败: {error}");
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
return;
}
};
let channel = match session.channel_direct_tcpip(&host, port, None) {
Ok(channel) => channel,
Err(error) => {
tracing::warn!("SOCKS5 目标 {host}:{port} 连接失败: {error}");
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
return;
}
};
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
thread_running.store(false, Ordering::Release);
run_direct_listener(
listener,
config,
DirectTarget::Dynamic,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -222,67 +171,65 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
remote_bind_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
) -> Result<ForwardStatus, String> {
validate_target(&local_host, local_port)?;
let local_host = validate_target(&local_host, local_port)?;
let remote_bind_host = validate_remote_bind_host(&remote_bind_host)?;
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let session = establish_session(&config)?;
let (mut listener, bound_port) = session
.channel_forward_listen(remote_port, None, None)
.map_err(|error| format!("建立远程端口转发失败: {error}"))?;
let want_all_interfaces = is_all_interfaces_bind(&remote_bind_host);
let (listener, bound_port, bind_host) =
listen_remote_forward(&session, remote_port, &remote_bind_host).map_err(|error| {
map_remote_forward_error(error, remote_port, want_all_interfaces)
})?;
session.set_blocking(false);
let descriptor = descriptor(
&forward_id,
connection_id,
"remote",
"remote",
if bind_host.is_empty() {
"localhost"
} else {
bind_host.as_str()
},
bound_port,
Some(local_host.clone()),
Some(local_port),
);
let fingerprint = connection_fingerprint(&config);
let cancel = Arc::new(AtomicBool::new(false));
let running = Arc::new(AtomicBool::new(true));
let counters = Arc::new(TunnelCounters::default());
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let thread_config = config.clone();
let thread_bind = remote_bind_host.clone();
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok(channel) => {
let Ok(socket) = TcpStream::connect((&*local_host, local_port)) else {
continue;
};
let session = session.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.code() == ErrorCode::Session(-37) => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
drop(listener);
let _ = session.disconnect(None, "port forward stopped", None);
thread_running.store(false, Ordering::Release);
run_remote_listener(
session,
listener,
thread_config,
remote_port,
thread_bind,
local_host,
local_port,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -327,18 +274,43 @@ impl TunnelManager {
.lock()
.map_err(lock_error)?
.values()
.filter(|runtime| {
connection_id.map_or(true, |id| runtime.descriptor.connection_id == id)
})
.filter(|runtime| connection_id.is_none_or(|id| runtime.descriptor.connection_id == id))
.map(TunnelRuntime::snapshot)
.collect::<Vec<_>>();
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
Ok(statuses)
}
/// Rebind listeners left behind when the pooled SSH connection was replaced.
/// Only takes runtimes whose SSH identity matches and whose previous connection
/// is no longer live — a second session to the same host must not steal them.
pub fn adopt_orphans(
&self,
new_connection_id: &str,
fingerprint: &str,
live_connection_ids: &HashSet<String>,
) -> Result<Vec<ForwardStatus>, String> {
let mut adopted = Vec::new();
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
for runtime in runtimes.values_mut() {
if runtime.fingerprint != fingerprint {
continue;
}
if runtime.descriptor.connection_id == new_connection_id {
continue;
}
if live_connection_ids.contains(&runtime.descriptor.connection_id) {
continue;
}
runtime.descriptor.connection_id = new_connection_id.to_string();
adopted.push(runtime.snapshot());
}
Ok(adopted)
}
/// Claim a forward id for start.
/// - Same connection + kind already running → return existing status (idempotent).
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
/// - Same connection + kind still running → return existing status (idempotent).
/// - Same id dead, owned by another connection, or a different kind → stop and reuse.
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
fn claim_id(
&self,
@@ -354,7 +326,8 @@ impl TunnelManager {
match runtimes.get(&id) {
Some(runtime)
if runtime.descriptor.connection_id == connection_id
&& runtime.descriptor.kind == kind =>
&& runtime.descriptor.kind == kind
&& runtime.running.load(Ordering::Acquire) =>
{
return Ok(IdClaim::Existing(runtime.snapshot()));
}
@@ -394,20 +367,322 @@ impl Drop for TunnelManager {
}
}
fn validate_target(host: &str, port: u16) -> Result<(), String> {
if host.trim().is_empty() || port == 0 {
pub(crate) fn connection_fingerprint(config: &SshConnectRequest) -> String {
format!(
"{}@{}:{}",
config.username.trim(),
config.host.trim().to_ascii_lowercase(),
config.port
)
}
#[derive(Clone)]
enum DirectTarget {
Fixed { host: String, port: u16 },
Dynamic,
}
/// Local / SOCKS listener loop. The TCP bind stays up until the user stops
/// the rule; SSH session flaps only rebuild the dedicated transport.
fn run_direct_listener(
listener: TcpListener,
config: SshConnectRequest,
target: DirectTarget,
cancel: Arc<AtomicBool>,
running: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
running.store(true, Ordering::Release);
let tunnel = std::sync::Arc::new(TunnelSession::new(config));
let mut last_keepalive = Instant::now();
while !cancel.load(Ordering::Acquire) {
if last_keepalive.elapsed() >= Duration::from_secs(15) {
tunnel.keepalive();
last_keepalive = Instant::now();
}
match listener.accept() {
Ok((socket, _)) => {
let cancel = cancel.clone();
let counters = counters.clone();
let tunnel = tunnel.clone();
let target = target.clone();
thread::spawn(move || {
handle_direct_client(socket, target, tunnel, cancel, counters);
});
}
Err(error)
if error.kind() == io::ErrorKind::WouldBlock
|| error.kind() == io::ErrorKind::Interrupted =>
{
thread::sleep(Duration::from_millis(25));
}
Err(error) => {
tracing::warn!("端口转发本地监听 accept 失败,继续等待: {error}");
thread::sleep(Duration::from_millis(100));
}
}
}
tunnel.close();
running.store(false, Ordering::Release);
}
fn handle_direct_client(
mut socket: TcpStream,
target: DirectTarget,
tunnel: std::sync::Arc<TunnelSession>,
cancel: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
return;
};
let is_dynamic = matches!(target, DirectTarget::Dynamic);
let (host, port) = match target {
DirectTarget::Fixed { host, port } => (host, port),
DirectTarget::Dynamic => match read_target(&mut socket) {
Ok(destination) => destination,
Err(error) => {
tracing::warn!("SOCKS5 请求解析失败: {error}");
return;
}
},
};
if cancel.load(Ordering::Acquire) {
return;
}
match tunnel.open_direct(&host, port, &cancel) {
Ok((session, channel)) => {
if is_dynamic {
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
}
relay_channel(session, channel, socket, cancel, counters);
}
Err(error) => {
tracing::warn!("端口转发目标 {host}:{port} 连接失败: {error}");
if is_dynamic {
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
}
}
}
}
fn run_remote_listener(
mut session: ssh2::Session,
mut listener: ssh2::Listener,
config: SshConnectRequest,
remote_port: u16,
remote_bind_host: String,
local_host: String,
local_port: u16,
cancel: Arc<AtomicBool>,
running: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
running.store(true, Ordering::Release);
while !cancel.load(Ordering::Acquire) {
let died = accept_remote_until_dead(
&session,
&mut listener,
&local_host,
local_port,
&cancel,
&counters,
);
if cancel.load(Ordering::Acquire) || !died {
break;
}
tracing::warn!("远程端口转发会话断开,正在重连 {remote_bind_host}:{remote_port}");
drop(listener);
let _ = session.disconnect(None, "port forward reconnect", None);
let Some((new_session, new_listener)) =
reconnect_remote_listener(&config, remote_port, &remote_bind_host, &cancel)
else {
running.store(false, Ordering::Release);
return;
};
session = new_session;
listener = new_listener;
}
drop(listener);
let _ = session.disconnect(None, "port forward stopped", None);
running.store(false, Ordering::Release);
}
fn accept_remote_until_dead(
session: &ssh2::Session,
listener: &mut ssh2::Listener,
local_host: &str,
local_port: u16,
cancel: &Arc<AtomicBool>,
counters: &Arc<TunnelCounters>,
) -> bool {
let mut last_keepalive = Instant::now();
while !cancel.load(Ordering::Acquire) {
if last_keepalive.elapsed() >= Duration::from_secs(15) {
let _ = session.keepalive_send();
last_keepalive = Instant::now();
}
match listener.accept() {
Ok(channel) => {
let Ok(socket) = TcpStream::connect((local_host, local_port)) else {
continue;
};
let session = session.clone();
let cancel = Arc::clone(cancel);
let counters = Arc::clone(counters);
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.code() == ErrorCode::Session(-37) => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => return true,
}
}
false
}
fn reconnect_remote_listener(
config: &SshConnectRequest,
remote_port: u16,
remote_bind_host: &str,
cancel: &AtomicBool,
) -> Option<(ssh2::Session, ssh2::Listener)> {
let mut delay = Duration::from_millis(400);
while !cancel.load(Ordering::Acquire) {
match establish_session(config) {
Ok(session) => match listen_remote_forward(&session, remote_port, remote_bind_host) {
Ok((listener, _, _)) => {
session.set_blocking(false);
return Some((session, listener));
}
Err(error) => {
tracing::warn!("远程转发重听失败: {error}");
let _ = session.disconnect(None, "remote re-listen failed", None);
}
},
Err(error) => {
tracing::warn!("远程转发重连失败: {error}");
}
}
thread::sleep(delay);
delay = (delay.saturating_mul(2)).min(Duration::from_secs(15));
}
None
}
fn validate_target(host: &str, port: u16) -> Result<String, String> {
let host = host.trim();
if host.is_empty() || port == 0 {
return Err("转发目标主机和端口不能为空".to_string());
}
Ok(())
// 0.0.0.0 / :: are listen-only; treat as remote loopback (common user mix-up).
if host == "0.0.0.0" || host == "::" || host.eq_ignore_ascii_case("[::]") {
return Ok("127.0.0.1".to_string());
}
Ok(host.to_string())
}
fn validate_bind_host(host: &str) -> Result<String, String> {
let host = host.trim();
if host.is_empty() {
return Ok("127.0.0.1".to_string());
}
if host.chars().any(char::is_whitespace) {
return Err("本地绑定地址不能包含空格".to_string());
}
Ok(host.to_string())
}
fn validate_remote_bind_host(host: &str) -> Result<String, String> {
let host = host.trim();
if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
return Ok("127.0.0.1".to_string());
}
if host.chars().any(char::is_whitespace) {
return Err("远端监听地址不能包含空格".to_string());
}
Ok(host.to_string())
}
fn is_all_interfaces_bind(host: &str) -> bool {
let host = host.trim();
host == "0.0.0.0" || host == "*" || host == "::" || host.eq_ignore_ascii_case("[::]")
}
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
"端口转发状态锁异常".to_string()
}
/// Ask sshd to listen for remote forwards.
/// Loopback: try localhost / 127.0.0.1 / empty (OpenSSH -R default).
/// All-interfaces (`0.0.0.0`): requires GatewayPorts yes/clientspecified.
fn listen_remote_forward(
session: &ssh2::Session,
remote_port: u16,
prefer_bind: &str,
) -> Result<(ssh2::Listener, u16, String), ssh2::Error> {
let prefer = prefer_bind.trim();
let candidates: Vec<&str> = if is_all_interfaces_bind(prefer) {
// OpenSSH clients send 0.0.0.0 (or *) when GatewayPorts allows it.
vec!["0.0.0.0"]
} else if prefer.is_empty() || prefer == "127.0.0.1" || prefer.eq_ignore_ascii_case("localhost")
{
// Order matters: PermitListen often lists "localhost:port"; empty string is
// what `ssh -R port:host:port` sends when no bind address is given.
vec!["localhost", "127.0.0.1", ""]
} else {
vec![prefer]
};
let mut denied: Option<ssh2::Error> = None;
for host in candidates {
match session.channel_forward_listen(remote_port, Some(host), Some(1)) {
Ok((listener, bound)) => {
let reported = if host.is_empty() {
"localhost".to_string()
} else {
host.to_string()
};
return Ok((listener, bound, reported));
}
Err(error) if error.code() == ErrorCode::Session(-32) => {
denied = Some(error);
}
Err(error) => return Err(error),
}
}
Err(denied.unwrap_or_else(|| ssh2::Error::from_errno(ErrorCode::Session(-32))))
}
/// libssh2 Session(-32) == LIBSSH2_ERROR_REQUEST_DENIED: sshd refused tcpip-forward.
fn map_remote_forward_error(
error: ssh2::Error,
remote_port: u16,
want_all_interfaces: bool,
) -> String {
match error.code() {
ErrorCode::Session(-32) if want_all_interfaces => format!(
"建立远程端口转发失败: 远端拒绝 0.0.0.0:{remote_port}(通常需 GatewayPorts yes/clientspecified,且端口空闲)。仅本机访问可改用 127.0.0.1:{remote_port}。"
),
ErrorCode::Session(-32) if (1..1024).contains(&remote_port) => format!(
"建立远程端口转发失败: 远端拒绝特权端口 {remote_port}(需 root 或改用 ≥1024;检查 AllowTcpForwarding / PermitListen)。"
),
ErrorCode::Session(-32) => format!(
"建立远程端口转发失败: 远端拒绝端口 {remote_port}(AllowTcpForwarding / PermitListen / 端口占用)。可用 ssh -R {remote_port}:127.0.0.1:<本地端口> 核对。"
),
_ => format!("建立远程端口转发失败: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn unreachable_config() -> SshConnectRequest {
SshConnectRequest {
@@ -496,4 +771,77 @@ mod tests {
let _ = first;
manager.stop_connection("connection-new").unwrap();
}
#[test]
fn adopt_orphans_rebinds_to_new_connection() {
let manager = TunnelManager::default();
let config = unreachable_config();
manager
.start_dynamic(Some("rule-1".to_string()), "old-conn", config.clone(), 0)
.unwrap();
let fingerprint = connection_fingerprint(&config);
let live = HashSet::from(["new-conn".to_string()]);
let adopted = manager
.adopt_orphans("new-conn", &fingerprint, &live)
.unwrap();
assert_eq!(adopted.len(), 1);
assert_eq!(adopted[0].connection_id, "new-conn");
assert!(manager.list(Some("old-conn")).unwrap().is_empty());
assert_eq!(
manager.list(Some("new-conn")).unwrap()[0].forward_id,
"rule-1"
);
manager.stop_connection("new-conn").unwrap();
}
#[test]
fn adopt_skips_runtimes_still_owned_by_live_connection() {
let manager = TunnelManager::default();
let config = unreachable_config();
manager
.start_dynamic(Some("rule-live".to_string()), "old-conn", config.clone(), 0)
.unwrap();
let fingerprint = connection_fingerprint(&config);
let live = HashSet::from(["old-conn".to_string(), "new-conn".to_string()]);
let adopted = manager
.adopt_orphans("new-conn", &fingerprint, &live)
.unwrap();
assert!(adopted.is_empty());
assert_eq!(manager.list(Some("old-conn")).unwrap().len(), 1);
assert!(manager.list(Some("new-conn")).unwrap().is_empty());
manager.stop_connection("old-conn").unwrap();
}
#[test]
fn dead_runtime_is_reclaimed_on_start() {
let manager = TunnelManager::default();
manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
{
let runtimes = manager.runtimes.lock().unwrap();
runtimes
.get("same-id")
.unwrap()
.running
.store(false, Ordering::Release);
}
let restarted = manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
assert_eq!(restarted.forward_id, "same-id");
assert!(restarted.running);
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
manager.stop_connection("connection-test").unwrap();
}
}
+3
View File
@@ -18,6 +18,9 @@ pub(super) struct TunnelCounters {
pub(super) struct TunnelRuntime {
pub(super) descriptor: ForwardStatus,
/// SSH identity this runtime was started from (`user@host:port`).
/// Used to rebind listeners onto a new connection id after reconnect.
pub(super) fingerprint: String,
pub(super) cancel: Arc<AtomicBool>,
pub(super) running: Arc<AtomicBool>,
pub(super) counters: Arc<TunnelCounters>,
+137
View File
@@ -0,0 +1,137 @@
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
thread,
time::Duration,
};
use ssh2::ErrorCode;
use crate::domain::ssh::SshConnectRequest;
use super::super::ssh::establish_session;
/// One long-lived SSH session per forwarding rule.
/// Local / dynamic listeners stay bound; this session is only the transport
/// used to open `direct-tcpip` channels. If it dies we reconnect it —
/// we do not tear down the local TCP listener.
pub(super) struct TunnelSession {
config: SshConnectRequest,
session: Mutex<Option<ssh2::Session>>,
}
impl TunnelSession {
pub(super) fn new(config: SshConnectRequest) -> Self {
Self {
config,
session: Mutex::new(None),
}
}
/// Warm the session so the first client does not pay a full handshake.
/// Failure is non-fatal: the local listener stays up and we retry later.
pub(super) fn warm(&self) {
if let Err(error) = self.ensure_session() {
tracing::warn!("端口转发预热 SSH 失败: {error}");
}
}
pub(super) fn open_direct(
&self,
host: &str,
port: u16,
cancel: &AtomicBool,
) -> Result<(ssh2::Session, ssh2::Channel), String> {
const ATTEMPTS: u32 = 2;
let mut last_error = "端口转发 SSH 不可用".to_string();
for _ in 1..=ATTEMPTS {
if cancel.load(Ordering::Acquire) {
return Err("端口转发已停止".to_string());
}
let session = match self.ensure_session() {
Ok(session) => session,
Err(error) => {
last_error = error;
self.invalidate();
continue;
}
};
match open_channel_nonblock(&session, host, port, cancel) {
Ok(channel) => return Ok((session, channel)),
Err(error) => {
last_error = error;
self.invalidate();
}
}
}
Err(last_error)
}
/// Send a protocol keepalive. If the session is gone, try to bring it back
/// so an idle SOCKS / local forward does not wait until the next client.
pub(super) fn keepalive(&self) {
let current = self.session.lock().ok().and_then(|guard| guard.clone());
match current {
Some(session) => {
if session.keepalive_send().is_err() {
tracing::warn!("端口转发 SSH 保活失败,将重建会话");
self.invalidate();
self.warm();
}
}
None => self.warm(),
}
}
pub(super) fn close(&self) {
self.invalidate();
}
fn ensure_session(&self) -> Result<ssh2::Session, String> {
let mut guard = self
.session
.lock()
.map_err(|_| "端口转发会话锁异常".to_string())?;
if let Some(session) = guard.as_ref() {
return Ok(session.clone());
}
let session = establish_session(&self.config)?;
*guard = Some(session.clone());
Ok(session)
}
fn invalidate(&self) {
let Ok(mut guard) = self.session.lock() else {
return;
};
if let Some(session) = guard.take() {
let _ = session.disconnect(None, "tunnel session reset", None);
}
}
}
fn open_channel_nonblock(
session: &ssh2::Session,
host: &str,
port: u16,
cancel: &AtomicBool,
) -> Result<ssh2::Channel, String> {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
if cancel.load(Ordering::Acquire) {
return Err("端口转发已停止".to_string());
}
match session.channel_direct_tcpip(host, port, None) {
Ok(channel) => return Ok(channel),
Err(error) if error.code() == ErrorCode::Session(-37) => {
if std::time::Instant::now() >= deadline {
return Err(format!("打开转发通道超时 {host}:{port}"));
}
thread::sleep(Duration::from_millis(15));
}
Err(error) => return Err(format!("打开转发通道失败 {host}:{port}: {error}")),
}
}
}