Files
opsxshell/src-tauri/src/services/tunnel.rs
T
Logan.LiandCursor 08696896f9 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>
2026-08-16 16:08:11 +08:00

848 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::{HashMap, HashSet},
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, Instant},
};
use ssh2::ErrorCode;
use uuid::Uuid;
use crate::domain::ssh::{ForwardStatus, SshConnectRequest};
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 {
Fresh(String),
Existing(ForwardStatus),
}
/// Owns forwarding listeners independently from persisted forwarding rules.
/// A rule may exist without a runtime; a runtime always has a cancellable id.
#[derive(Default)]
pub struct TunnelManager {
runtimes: Mutex<HashMap<String, TunnelRuntime>>,
}
impl TunnelManager {
pub fn start_local(
&self,
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> {
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((bind_host.as_str(), local_port))
.map_err(|error| format!("监听本地端口失败: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("设置本地端口非阻塞模式失败: {error}"))?;
let bound_port = listener
.local_addr()
.map_err(|error| error.to_string())?
.port();
let descriptor = descriptor(
&forward_id,
connection_id,
"local",
&bind_host,
bound_port,
Some(remote_host.clone()),
Some(remote_port),
);
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 fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
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,
listener_thread: Some(listener_thread),
},
)
}
pub fn start_dynamic(
&self,
forward_id: Option<String>,
connection_id: &str,
config: SshConnectRequest,
local_port: u16,
) -> Result<ForwardStatus, String> {
let forward_id = match self.claim_id(forward_id, connection_id, "dynamic")? {
IdClaim::Existing(status) => return Ok(status),
IdClaim::Fresh(id) => id,
};
let listener = TcpListener::bind(("127.0.0.1", local_port))
.map_err(|error| format!("监听 SOCKS5 端口失败: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("设置 SOCKS5 端口非阻塞模式失败: {error}"))?;
let bound_port = listener
.local_addr()
.map_err(|error| error.to_string())?
.port();
let descriptor = descriptor(
&forward_id,
connection_id,
"dynamic",
"127.0.0.1",
bound_port,
None,
None,
);
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 fingerprint = connection_fingerprint(&config);
let listener_thread = thread::spawn(move || {
run_direct_listener(
listener,
config,
DirectTarget::Dynamic,
thread_cancel,
thread_running,
thread_counters,
);
});
self.register(
forward_id,
TunnelRuntime {
descriptor,
fingerprint,
cancel,
running,
counters,
listener_thread: Some(listener_thread),
},
)
}
pub fn start_remote(
&self,
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> {
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 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",
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 || {
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,
listener_thread: Some(listener_thread),
},
)
}
pub fn stop(&self, forward_id: &str) -> Result<(), String> {
let runtime = self
.runtimes
.lock()
.map_err(lock_error)?
.remove(forward_id)
.ok_or_else(|| format!("端口转发不存在或已停止: {forward_id}"))?;
runtime.stop();
Ok(())
}
pub fn stop_connection(&self, connection_id: &str) -> Result<(), String> {
let runtimes = {
let mut all = self.runtimes.lock().map_err(lock_error)?;
let ids = all
.iter()
.filter_map(|(id, runtime)| {
(runtime.descriptor.connection_id == connection_id).then_some(id.clone())
})
.collect::<Vec<_>>();
ids.into_iter()
.filter_map(|id| all.remove(&id))
.collect::<Vec<_>>()
};
for runtime in runtimes {
runtime.stop();
}
Ok(())
}
pub fn list(&self, connection_id: Option<&str>) -> Result<Vec<ForwardStatus>, String> {
let mut statuses = self
.runtimes
.lock()
.map_err(lock_error)?
.values()
.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 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,
requested: Option<String>,
connection_id: &str,
kind: &str,
) -> Result<IdClaim, String> {
let id = requested
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| format!("forward-{}", Uuid::new_v4()));
let stale = {
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
match runtimes.get(&id) {
Some(runtime)
if runtime.descriptor.connection_id == connection_id
&& runtime.descriptor.kind == kind
&& runtime.running.load(Ordering::Acquire) =>
{
return Ok(IdClaim::Existing(runtime.snapshot()));
}
Some(_) => runtimes.remove(&id),
None => None,
}
};
if let Some(runtime) = stale {
runtime.stop();
}
Ok(IdClaim::Fresh(id))
}
fn register(&self, id: String, runtime: TunnelRuntime) -> Result<ForwardStatus, String> {
let status = runtime.snapshot();
let mut runtimes = self.runtimes.lock().map_err(lock_error)?;
match runtimes.entry(id) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(runtime);
Ok(status)
}
std::collections::hash_map::Entry::Occupied(_) => {
runtime.stop();
Err("端口转发 ID 冲突".to_string())
}
}
}
}
impl Drop for TunnelManager {
fn drop(&mut self) {
if let Ok(runtimes) = self.runtimes.get_mut() {
for (_, runtime) in runtimes.drain() {
runtime.stop();
}
}
}
}
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());
}
// 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 {
host: "127.0.0.1".to_string(),
port: 1,
username: "test".to_string(),
password: None,
private_key: None,
passphrase: None,
proxy: None,
jump_host: None,
jump_hosts: None,
timeout_seconds: Some(3),
keep_alive: Some(true),
}
}
#[test]
fn dynamic_listener_has_cancellable_runtime_state() {
let manager = TunnelManager::default();
let status = manager
.start_dynamic(
Some("proxy-test".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
assert_eq!(status.forward_id, "proxy-test");
assert!(status.bound_port > 0);
assert!(manager.list(Some("connection-test")).unwrap()[0].running);
manager.stop("proxy-test").unwrap();
assert!(manager.list(None).unwrap().is_empty());
}
#[test]
fn duplicate_runtime_id_is_idempotent_for_same_connection() {
let manager = TunnelManager::default();
let first = manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
let second = manager
.start_dynamic(
Some("same-id".to_string()),
"connection-test",
unreachable_config(),
0,
)
.unwrap();
assert_eq!(first.forward_id, second.forward_id);
assert_eq!(first.bound_port, second.bound_port);
assert_eq!(manager.list(Some("connection-test")).unwrap().len(), 1);
manager.stop_connection("connection-test").unwrap();
}
#[test]
fn stale_runtime_id_from_other_connection_is_reclaimed() {
let manager = TunnelManager::default();
let first = manager
.start_dynamic(
Some("shared-rule".to_string()),
"connection-old",
unreachable_config(),
0,
)
.unwrap();
let second = manager
.start_dynamic(
Some("shared-rule".to_string()),
"connection-new",
unreachable_config(),
0,
)
.unwrap();
assert_eq!(second.forward_id, "shared-rule");
assert_eq!(second.connection_id, "connection-new");
assert!(manager.list(Some("connection-old")).unwrap().is_empty());
assert_eq!(manager.list(Some("connection-new")).unwrap().len(), 1);
// Old listener was stopped; new bind may reuse the port or pick another (port 0).
assert_ne!(second.bound_port, 0);
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();
}
}