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:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user