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
+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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user