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

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

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

No files matched your search

+471 -123
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
@@ -7,7 +7,7 @@ use std::{
Arc, Mutex,
},
thread,
time::Duration,
time::{Duration, Instant},
};
use ssh2::ErrorCode;
@@ -19,10 +19,12 @@ use super::ssh::establish_session;
mod model;
mod relay;
mod session;
mod socks5;
use model::{descriptor, ConnectionGuard, TunnelCounters, TunnelRuntime};
use relay::relay_channel;
use session::TunnelSession;
use socks5::read_target;
enum IdClaim {
@@ -43,16 +45,18 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
local_bind_host: String,
local_port: u16,
remote_host: String,
remote_port: u16,
) -> Result<ForwardStatus, String> {
validate_target(&remote_host, remote_port)?;
let remote_host = validate_target(&remote_host, remote_port)?;
let bind_host = validate_bind_host(&local_bind_host)?;
let forward_id = match self.claim_id(forward_id, connection_id, "local")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let listener = TcpListener::bind(("127.0.0.1", local_port))
let listener = TcpListener::bind((bind_host.as_str(), local_port))
.map_err(|error| format!("监听本地端口失败: {error}"))?;
listener
.set_nonblocking(true)
@@ -65,7 +69,7 @@ impl TunnelManager {
&forward_id,
connection_id,
"local",
"127.0.0.1",
&bind_host,
bound_port,
Some(remote_host.clone()),
Some(remote_port),
@@ -76,42 +80,25 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((socket, _)) => {
let config = config.clone();
let host = remote_host.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
let Ok(session) = establish_session(&config) else {
return;
};
let Ok(channel) =
session.channel_direct_tcpip(&host, remote_port, None)
else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
thread_running.store(false, Ordering::Release);
run_direct_listener(
listener,
config,
DirectTarget::Fixed {
host: remote_host,
port: remote_port,
},
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -155,60 +142,22 @@ impl TunnelManager {
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok((mut socket, _)) => {
let config = config.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
let (host, port) = match read_target(&mut socket) {
Ok(target) => target,
Err(error) => {
tracing::warn!("SOCKS5 请求解析失败: {error}");
return;
}
};
if cancel.load(Ordering::Acquire) {
return;
}
let session = match establish_session(&config) {
Ok(session) => session,
Err(error) => {
tracing::warn!("SOCKS5 专用 SSH 连接失败: {error}");
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
return;
}
};
let channel = match session.channel_direct_tcpip(&host, port, None) {
Ok(channel) => channel,
Err(error) => {
tracing::warn!("SOCKS5 目标 {host}:{port} 连接失败: {error}");
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
return;
}
};
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
thread_running.store(false, Ordering::Release);
run_direct_listener(
listener,
config,
DirectTarget::Dynamic,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -222,67 +171,65 @@ impl TunnelManager {
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
remote_bind_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
) -> Result<ForwardStatus, String> {
validate_target(&local_host, local_port)?;
let local_host = validate_target(&local_host, local_port)?;
let remote_bind_host = validate_remote_bind_host(&remote_bind_host)?;
let forward_id = match self.claim_id(forward_id, connection_id, "remote")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let session = establish_session(&config)?;
let (mut listener, bound_port) = session
.channel_forward_listen(remote_port, None, None)
.map_err(|error| format!("建立远程端口转发失败: {error}"))?;
let want_all_interfaces = is_all_interfaces_bind(&remote_bind_host);
let (listener, bound_port, bind_host) =
listen_remote_forward(&session, remote_port, &remote_bind_host).map_err(|error| {
map_remote_forward_error(error, remote_port, want_all_interfaces)
})?;
session.set_blocking(false);
let descriptor = descriptor(
&forward_id,
connection_id,
"remote",
"remote",
if bind_host.is_empty() {
"localhost"
} else {
bind_host.as_str()
},
bound_port,
Some(local_host.clone()),
Some(local_port),
);
let fingerprint = connection_fingerprint(&config);
let cancel = Arc::new(AtomicBool::new(false));
let running = Arc::new(AtomicBool::new(true));
let counters = Arc::new(TunnelCounters::default());
let thread_cancel = cancel.clone();
let thread_running = running.clone();
let thread_counters = counters.clone();
let thread_config = config.clone();
let thread_bind = remote_bind_host.clone();
let listener_thread = thread::spawn(move || {
while !thread_cancel.load(Ordering::Acquire) {
match listener.accept() {
Ok(channel) => {
let Ok(socket) = TcpStream::connect((&*local_host, local_port)) else {
continue;
};
let session = session.clone();
let cancel = thread_cancel.clone();
let counters = thread_counters.clone();
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel)
else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.code() == ErrorCode::Session(-37) => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => break,
}
}
drop(listener);
let _ = session.disconnect(None, "port forward stopped", None);
thread_running.store(false, Ordering::Release);
run_remote_listener(
session,
listener,
thread_config,
remote_port,
thread_bind,
local_host,
local_port,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
@@ -327,18 +274,43 @@ impl TunnelManager {
.lock()
.map_err(lock_error)?
.values()
.filter(|runtime| {
connection_id.map_or(true, |id| runtime.descriptor.connection_id == id)
})
.filter(|runtime| connection_id.is_none_or(|id| runtime.descriptor.connection_id == id))
.map(TunnelRuntime::snapshot)
.collect::<Vec<_>>();
statuses.sort_by(|left, right| left.forward_id.cmp(&right.forward_id));
Ok(statuses)
}
/// Rebind listeners left behind when the pooled SSH connection was replaced.
/// Only takes runtimes whose SSH identity matches and whose previous connection
/// is no longer live — a second session to the same host must not steal them.
pub fn adopt_orphans(
&self,
new_connection_id: &str,
fingerprint: &str,
live_connection_ids: &HashSet<String>,
) -> Result<Vec<ForwardStatus>, String> {
let mut adopted = Vec::new();
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
for runtime in runtimes.values_mut() {
if runtime.fingerprint != fingerprint {
continue;
}
if runtime.descriptor.connection_id == new_connection_id {
continue;
}
if live_connection_ids.contains(&runtime.descriptor.connection_id) {
continue;
}
runtime.descriptor.connection_id = new_connection_id.to_string();
adopted.push(runtime.snapshot());
}
Ok(adopted)
}
/// Claim a forward id for start.
/// - Same connection + kind already running → return existing status (idempotent).
/// - Same id owned by another connection (or different kind) → stop stale runtime and reuse.
/// - Same connection + kind still running → return existing status (idempotent).
/// - Same id dead, owned by another connection, or a different kind → stop and reuse.
/// Session-scoped rules keep a stable id across reconnects; orphans must not block start.
fn claim_id(
&self,
@@ -354,7 +326,8 @@ impl TunnelManager {
match runtimes.get(&id) {
Some(runtime)
if runtime.descriptor.connection_id == connection_id
&& runtime.descriptor.kind == kind =>
&& runtime.descriptor.kind == kind
&& runtime.running.load(Ordering::Acquire) =>
{
return Ok(IdClaim::Existing(runtime.snapshot()));
}
@@ -394,20 +367,322 @@ impl Drop for TunnelManager {
}
}
fn validate_target(host: &str, port: u16) -> Result<(), String> {
if host.trim().is_empty() || port == 0 {
pub(crate) fn connection_fingerprint(config: &SshConnectRequest) -> String {
format!(
"{}@{}:{}",
config.username.trim(),
config.host.trim().to_ascii_lowercase(),
config.port
)
}
#[derive(Clone)]
enum DirectTarget {
Fixed { host: String, port: u16 },
Dynamic,
}
/// Local / SOCKS listener loop. The TCP bind stays up until the user stops
/// the rule; SSH session flaps only rebuild the dedicated transport.
fn run_direct_listener(
listener: TcpListener,
config: SshConnectRequest,
target: DirectTarget,
cancel: Arc<AtomicBool>,
running: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
running.store(true, Ordering::Release);
let tunnel = std::sync::Arc::new(TunnelSession::new(config));
let mut last_keepalive = Instant::now();
while !cancel.load(Ordering::Acquire) {
if last_keepalive.elapsed() >= Duration::from_secs(15) {
tunnel.keepalive();
last_keepalive = Instant::now();
}
match listener.accept() {
Ok((socket, _)) => {
let cancel = cancel.clone();
let counters = counters.clone();
let tunnel = tunnel.clone();
let target = target.clone();
thread::spawn(move || {
handle_direct_client(socket, target, tunnel, cancel, counters);
});
}
Err(error)
if error.kind() == io::ErrorKind::WouldBlock
|| error.kind() == io::ErrorKind::Interrupted =>
{
thread::sleep(Duration::from_millis(25));
}
Err(error) => {
tracing::warn!("端口转发本地监听 accept 失败,继续等待: {error}");
thread::sleep(Duration::from_millis(100));
}
}
}
tunnel.close();
running.store(false, Ordering::Release);
}
fn handle_direct_client(
mut socket: TcpStream,
target: DirectTarget,
tunnel: std::sync::Arc<TunnelSession>,
cancel: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
return;
};
let is_dynamic = matches!(target, DirectTarget::Dynamic);
let (host, port) = match target {
DirectTarget::Fixed { host, port } => (host, port),
DirectTarget::Dynamic => match read_target(&mut socket) {
Ok(destination) => destination,
Err(error) => {
tracing::warn!("SOCKS5 请求解析失败: {error}");
return;
}
},
};
if cancel.load(Ordering::Acquire) {
return;
}
match tunnel.open_direct(&host, port, &cancel) {
Ok((session, channel)) => {
if is_dynamic {
let _ = socket.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
}
relay_channel(session, channel, socket, cancel, counters);
}
Err(error) => {
tracing::warn!("端口转发目标 {host}:{port} 连接失败: {error}");
if is_dynamic {
let _ = socket.write_all(&[5, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
}
}
}
}
fn run_remote_listener(
mut session: ssh2::Session,
mut listener: ssh2::Listener,
config: SshConnectRequest,
remote_port: u16,
remote_bind_host: String,
local_host: String,
local_port: u16,
cancel: Arc<AtomicBool>,
running: Arc<AtomicBool>,
counters: Arc<TunnelCounters>,
) {
running.store(true, Ordering::Release);
while !cancel.load(Ordering::Acquire) {
let died = accept_remote_until_dead(
&session,
&mut listener,
&local_host,
local_port,
&cancel,
&counters,
);
if cancel.load(Ordering::Acquire) || !died {
break;
}
tracing::warn!("远程端口转发会话断开,正在重连 {remote_bind_host}:{remote_port}");
drop(listener);
let _ = session.disconnect(None, "port forward reconnect", None);
let Some((new_session, new_listener)) =
reconnect_remote_listener(&config, remote_port, &remote_bind_host, &cancel)
else {
running.store(false, Ordering::Release);
return;
};
session = new_session;
listener = new_listener;
}
drop(listener);
let _ = session.disconnect(None, "port forward stopped", None);
running.store(false, Ordering::Release);
}
fn accept_remote_until_dead(
session: &ssh2::Session,
listener: &mut ssh2::Listener,
local_host: &str,
local_port: u16,
cancel: &Arc<AtomicBool>,
counters: &Arc<TunnelCounters>,
) -> bool {
let mut last_keepalive = Instant::now();
while !cancel.load(Ordering::Acquire) {
if last_keepalive.elapsed() >= Duration::from_secs(15) {
let _ = session.keepalive_send();
last_keepalive = Instant::now();
}
match listener.accept() {
Ok(channel) => {
let Ok(socket) = TcpStream::connect((local_host, local_port)) else {
continue;
};
let session = session.clone();
let cancel = Arc::clone(cancel);
let counters = Arc::clone(counters);
thread::spawn(move || {
let Some(_guard) = ConnectionGuard::start(counters.clone(), &cancel) else {
return;
};
relay_channel(session, channel, socket, cancel, counters);
});
}
Err(error) if error.code() == ErrorCode::Session(-37) => {
thread::sleep(Duration::from_millis(25));
}
Err(_) => return true,
}
}
false
}
fn reconnect_remote_listener(
config: &SshConnectRequest,
remote_port: u16,
remote_bind_host: &str,
cancel: &AtomicBool,
) -> Option<(ssh2::Session, ssh2::Listener)> {
let mut delay = Duration::from_millis(400);
while !cancel.load(Ordering::Acquire) {
match establish_session(config) {
Ok(session) => match listen_remote_forward(&session, remote_port, remote_bind_host) {
Ok((listener, _, _)) => {
session.set_blocking(false);
return Some((session, listener));
}
Err(error) => {
tracing::warn!("远程转发重听失败: {error}");
let _ = session.disconnect(None, "remote re-listen failed", None);
}
},
Err(error) => {
tracing::warn!("远程转发重连失败: {error}");
}
}
thread::sleep(delay);
delay = (delay.saturating_mul(2)).min(Duration::from_secs(15));
}
None
}
fn validate_target(host: &str, port: u16) -> Result<String, String> {
let host = host.trim();
if host.is_empty() || port == 0 {
return Err("转发目标主机和端口不能为空".to_string());
}
Ok(())
// 0.0.0.0 / :: are listen-only; treat as remote loopback (common user mix-up).
if host == "0.0.0.0" || host == "::" || host.eq_ignore_ascii_case("[::]") {
return Ok("127.0.0.1".to_string());
}
Ok(host.to_string())
}
fn validate_bind_host(host: &str) -> Result<String, String> {
let host = host.trim();
if host.is_empty() {
return Ok("127.0.0.1".to_string());
}
if host.chars().any(char::is_whitespace) {
return Err("本地绑定地址不能包含空格".to_string());
}
Ok(host.to_string())
}
fn validate_remote_bind_host(host: &str) -> Result<String, String> {
let host = host.trim();
if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
return Ok("127.0.0.1".to_string());
}
if host.chars().any(char::is_whitespace) {
return Err("远端监听地址不能包含空格".to_string());
}
Ok(host.to_string())
}
fn is_all_interfaces_bind(host: &str) -> bool {
let host = host.trim();
host == "0.0.0.0" || host == "*" || host == "::" || host.eq_ignore_ascii_case("[::]")
}
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
"端口转发状态锁异常".to_string()
}
/// Ask sshd to listen for remote forwards.
/// Loopback: try localhost / 127.0.0.1 / empty (OpenSSH -R default).
/// All-interfaces (`0.0.0.0`): requires GatewayPorts yes/clientspecified.
fn listen_remote_forward(
session: &ssh2::Session,
remote_port: u16,
prefer_bind: &str,
) -> Result<(ssh2::Listener, u16, String), ssh2::Error> {
let prefer = prefer_bind.trim();
let candidates: Vec<&str> = if is_all_interfaces_bind(prefer) {
// OpenSSH clients send 0.0.0.0 (or *) when GatewayPorts allows it.
vec!["0.0.0.0"]
} else if prefer.is_empty() || prefer == "127.0.0.1" || prefer.eq_ignore_ascii_case("localhost")
{
// Order matters: PermitListen often lists "localhost:port"; empty string is
// what `ssh -R port:host:port` sends when no bind address is given.
vec!["localhost", "127.0.0.1", ""]
} else {
vec![prefer]
};
let mut denied: Option<ssh2::Error> = None;
for host in candidates {
match session.channel_forward_listen(remote_port, Some(host), Some(1)) {
Ok((listener, bound)) => {
let reported = if host.is_empty() {
"localhost".to_string()
} else {
host.to_string()
};
return Ok((listener, bound, reported));
}
Err(error) if error.code() == ErrorCode::Session(-32) => {
denied = Some(error);
}
Err(error) => return Err(error),
}
}
Err(denied.unwrap_or_else(|| ssh2::Error::from_errno(ErrorCode::Session(-32))))
}
/// libssh2 Session(-32) == LIBSSH2_ERROR_REQUEST_DENIED: sshd refused tcpip-forward.
fn map_remote_forward_error(
error: ssh2::Error,
remote_port: u16,
want_all_interfaces: bool,
) -> String {
match error.code() {
ErrorCode::Session(-32) if want_all_interfaces => format!(
"建立远程端口转发失败: 远端拒绝 0.0.0.0:{remote_port}(通常需 GatewayPorts yes/clientspecified,且端口空闲)。仅本机访问可改用 127.0.0.1:{remote_port}。"
),
ErrorCode::Session(-32) if (1..1024).contains(&remote_port) => format!(
"建立远程端口转发失败: 远端拒绝特权端口 {remote_port}(需 root 或改用 ≥1024;检查 AllowTcpForwarding / PermitListen)。"
),
ErrorCode::Session(-32) => format!(
"建立远程端口转发失败: 远端拒绝端口 {remote_port}(AllowTcpForwarding / PermitListen / 端口占用)。可用 ssh -R {remote_port}:127.0.0.1:<本地端口> 核对。"
),
_ => format!("建立远程端口转发失败: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn unreachable_config() -> SshConnectRequest {
SshConnectRequest {
@@ -496,4 +771,77 @@ mod tests {
let _ = first;
manager.stop_connection("connection-new").unwrap();
}
#[test]
fn adopt_orphans_rebinds_to_new_connection() {
let manager = TunnelManager::default();
let config = unreachable_config();
manager
.start_dynamic(Some("rule-1".to_string()), "old-conn", config.clone(), 0)
.unwrap();
let fingerprint = connection_fingerprint(&config);
let live = HashSet::from(["new-conn".to_string()]);
let adopted = manager
.adopt_orphans("new-conn", &fingerprint, &live)
.unwrap();
assert_eq!(adopted.len(), 1);
assert_eq!(adopted[0].connection_id, "new-conn");
assert!(manager.list(Some("old-conn")).unwrap().is_empty());
assert_eq!(
manager.list(Some("new-conn")).unwrap()[0].forward_id,
"rule-1"
);
manager.stop_connection("new-conn").unwrap();
}
#[test]
fn adopt_skips_runtimes_still_owned_by_live_connection() {
let manager = TunnelManager::default();
let config = unreachable_config();
manager
.start_dynamic(Some("rule-live".to_string()), "old-conn", config.clone(), 0)
.unwrap();
let fingerprint = connection_fingerprint(&config);
let live = HashSet::from(["old-conn".to_string(), "new-conn".to_string()]);
let adopted = manager
.adopt_orphans("new-conn", &fingerprint, &live)
.unwrap();
assert!(adopted.is_empty());
assert_eq!(manager.list(Some("old-conn")).unwrap().len(), 1);
assert!(manager.list(Some("new-conn")).unwrap().is_empty());
manager.stop_connection("old-conn").unwrap();
}
#[test]
fn dead_runtime_is_reclaimed_on_start() {
let manager = TunnelManager::default();
manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
{
let runtimes = manager.runtimes.lock().unwrap();
runtimes
.get("same-id")
.unwrap()
.running
.store(false, Ordering::Release);
}
let restarted = manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
assert_eq!(restarted.forward_id, "same-id");
assert!(restarted.running);
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
manager.stop_connection("connection-test").unwrap();
}
}