Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use bitfun_core::miniapp::{
initialize_global_miniapp_manager, seed_builtin_miniapps, JsWorkerPool, MiniAppManager,
};
use bitfun_core::service::remote_ssh::{
init_remote_workspace_manager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager,
init_remote_workspace_manager, PortForwardManager, RemoteFileService, RemoteTerminalManager,
SSHConnectionManager,
};
use bitfun_core::service::{announcement, config, filesystem, mcp, search, token_usage, workspace};
use bitfun_core::util::errors::*;
Expand Down Expand Up @@ -86,6 +87,10 @@ pub struct AppState {
pub ssh_manager: Arc<RwLock<Option<SSHConnectionManager>>>,
pub remote_file_service: Arc<RwLock<Option<RemoteFileService>>>,
pub remote_terminal_manager: Arc<RwLock<Option<RemoteTerminalManager>>>,
/// Local (`-L`) port forwards. Unlike the services above this needs no
/// `Option`: it is useful before any connection exists, and its own entry
/// points report a missing SSH manager.
pub port_forward_manager: PortForwardManager,
pub remote_workspace: Arc<RwLock<Option<RemoteWorkspace>>>,
pub active_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
/// Cancellation flags for active file transfers (download/upload), keyed by transfer_id.
Expand Down Expand Up @@ -304,6 +309,8 @@ impl AppState {
let manager_for_fs = Arc::new(tokio::sync::RwLock::new(Some(manager_arc.as_ref().clone())));
let fs = RemoteFileService::new(manager_for_fs.clone());
let tm = RemoteTerminalManager::new(manager_arc.as_ref().clone());
let port_forward_manager =
PortForwardManager::with_ssh_manager(manager_arc.as_ref().clone());

// Clone for storing in AppState
let fs_for_state = fs.clone();
Expand Down Expand Up @@ -346,6 +353,7 @@ impl AppState {
ssh_manager,
remote_file_service,
remote_terminal_manager,
port_forward_manager,
remote_workspace,
active_searches: Arc::new(Mutex::new(HashMap::new())),
active_transfers: Arc::new(Mutex::new(HashMap::new())),
Expand Down
84 changes: 81 additions & 3 deletions src/apps/desktop/src/api/ssh_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use crate::api::app_state::SSHServiceError;
use crate::startup_trace::DesktopStartupTrace;
use crate::AppState;
use bitfun_core::service::remote_ssh::{
ConnectionTestReport, DockerContainerInfo, RemoteTreeNode, SSHAuthMethod, SSHConfigEntry,
list_remote_listening_ports, ConnectionTestReport, DockerContainerInfo, PortForward,
PortForwardRequest, RemoteListeningPort, RemoteTreeNode, SSHAuthMethod, SSHConfigEntry,
SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionManager, SSHConnectionResult,
SavedConnection, ServerInfo,
};
Expand Down Expand Up @@ -188,6 +189,13 @@ pub async fn ssh_disconnect(
connection_id: String,
) -> Result<(), String> {
let manager = state.get_ssh_manager_async().await?;
// Tear the forwards down first. They are listeners whose only meaning is
// this session, and leaving one running would let a later connection on it
// reconnect the host the user just closed.
state
.port_forward_manager
.stop_for_connection(&connection_id)
.await;
manager
.disconnect(&connection_id)
.await
Expand All @@ -197,6 +205,7 @@ pub async fn ssh_disconnect(
#[tauri::command]
pub async fn ssh_disconnect_all(state: State<'_, AppState>) -> Result<(), String> {
let manager = state.get_ssh_manager_async().await?;
state.port_forward_manager.stop_all().await;
manager.disconnect_all().await;
Ok(())
}
Expand Down Expand Up @@ -540,8 +549,7 @@ fn validate_remote_name_for_local_download(name: &str) -> Result<(), String> {
fn local_download_name_key(name: &str) -> String {
#[cfg(any(windows, target_os = "macos"))]
{
name.trim_end_matches(['.', ' '])
.to_lowercase()
name.trim_end_matches(['.', ' ']).to_lowercase()
}
#[cfg(not(any(windows, target_os = "macos")))]
{
Expand Down Expand Up @@ -1121,6 +1129,76 @@ pub async fn remote_get_workspace_info(
Ok(workspace)
}

// === Port Forwarding ===

/// Start a local (`-L`) forward and return the mapping that was established.
///
/// The returned `localPort` is not always the requested one: an unavailable
/// port is replaced rather than refused, and `requestedLocalPort` carries what
/// was asked for so the UI can say so.
#[tauri::command]
pub async fn ssh_start_port_forward(
state: State<'_, AppState>,
request: PortForwardRequest,
) -> Result<PortForward, String> {
log::info!(
"ssh_start_port_forward: connection={}, remote={}:{}, requested local port={:?}",
request.connection_id,
request.effective_remote_host(),
request.remote_port,
request.preferred_local_port()
);
state
.port_forward_manager
.start_local_forward(&request)
.await
.map_err(|error| format!("{error:#}"))
}

#[tauri::command]
pub async fn ssh_stop_port_forward(
state: State<'_, AppState>,
forward_id: String,
) -> Result<(), String> {
state
.port_forward_manager
.stop_forward(&forward_id)
.await
.map_err(|error| format!("{error:#}"))
}

/// List forwards, optionally narrowed to one connection.
#[tauri::command]
pub async fn ssh_list_port_forwards(
state: State<'_, AppState>,
connection_id: Option<String>,
) -> Result<Vec<PortForward>, String> {
Ok(match connection_id {
Some(connection_id) => {
state
.port_forward_manager
.list_forwards_for_connection(&connection_id)
.await
}
None => state.port_forward_manager.list_forwards().await,
})
}

/// List the TCP ports currently accepting connections on the remote host.
///
/// Discovery only. Nothing is forwarded as a result of calling this; it exists
/// so users can pick a real port instead of guessing one.
#[tauri::command]
pub async fn ssh_list_remote_listening_ports(
state: State<'_, AppState>,
connection_id: String,
) -> Result<Vec<RemoteListeningPort>, String> {
let manager = state.get_ssh_manager_async().await?;
list_remote_listening_ports(&manager, &connection_id)
.await
.map_err(|error| format!("{error:#}"))
}

#[cfg(test)]
mod tests {
use super::{
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1889,6 +1889,11 @@ pub async fn run() {
api::ssh_api::remote_close_workspace,
api::ssh_api::remote_remove_workspace,
api::ssh_api::remote_get_workspace_info,
// Remote port forwarding (local `-L` mappings, user-driven)
api::ssh_api::ssh_start_port_forward,
api::ssh_api::ssh_stop_port_forward,
api::ssh_api::ssh_list_port_forwards,
api::ssh_api::ssh_list_remote_listening_ports,
// Detached task dispatch (controller-side SSH transport)
api::dispatch_api::dispatch_list_targets,
api::dispatch_api::dispatch_probe_target,
Expand Down
6 changes: 3 additions & 3 deletions src/crates/assembly/core/src/service/remote_ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ pub use bitfun_services_integrations::remote_ssh::{
pub use bitfun_services_integrations::remote_ssh::{dispatch_ssh, relay_deploy};
#[cfg(not(feature = "ssh-remote"))]
pub use bitfun_services_integrations::remote_ssh::{
get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward,
PortForwardDirection, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse,
get_global_remote_exec_process_manager, list_remote_listening_ports, KnownHostEntry,
PTYSession, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse,
RemoteExecControlAction, RemoteExecControlOrigin, RemoteExecControlRequest, RemoteExecError,
RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecProcessManager,
RemoteExecResult, RemoteExecSessionCompletion, RemoteExecSessionCompletionSource,
Expand All @@ -39,7 +39,7 @@ pub use bitfun_services_integrations::remote_ssh::{
};
#[cfg(feature = "ssh-remote")]
pub use bitfun_services_integrations::remote_ssh::{
KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager,
list_remote_listening_ports, KnownHostEntry, PTYSession, PortForwardManager,
SSHConnectionManager,
};
#[cfg(feature = "ssh-remote")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
//! stay behind `ssh-remote` and return explicit unsupported errors here.

use crate::remote_ssh::types::{
RemoteDirEntry, RemoteFileEntry, RemoteTreeNode, SSHCommandOptions, SSHCommandResult,
SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionResult,
SavedConnection, ServerInfo,
PortForward, PortForwardRequest, RemoteDirEntry, RemoteFileEntry, RemoteListeningPort,
RemoteTreeNode, SSHCommandOptions, SSHCommandResult, SSHConfigEntry, SSHConfigLookupResult,
SSHConnectionConfig, SSHConnectionResult, SavedConnection, ServerInfo,
};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
Expand Down Expand Up @@ -591,22 +591,6 @@ impl PTYSession {
}
}

#[derive(Debug, Clone)]
pub struct PortForward {
pub id: String,
pub local_port: u16,
pub remote_host: String,
pub remote_port: u16,
pub direction: PortForwardDirection,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PortForwardDirection {
Local,
Remote,
Dynamic,
}

#[derive(Clone, Default)]
pub struct PortForwardManager;

Expand All @@ -623,11 +607,8 @@ impl PortForwardManager {

pub async fn start_local_forward(
&self,
_connection_id: &str,
_local_port: u16,
_remote_host: &str,
_remote_port: u16,
) -> anyhow::Result<String> {
_request: &PortForwardRequest,
) -> anyhow::Result<PortForward> {
Err(unsupported())
}

Expand All @@ -645,17 +626,41 @@ impl PortForwardManager {
Err(unsupported())
}

pub async fn stop_for_connection(&self, _connection_id: &str) -> usize {
0
}

pub async fn stop_all(&self) {}

pub async fn list_forwards(&self) -> Vec<PortForward> {
Vec::new()
}

pub async fn list_forwards_for_connection(&self, _connection_id: &str) -> Vec<PortForward> {
Vec::new()
}

pub async fn find_forward(
&self,
_connection_id: &str,
_remote_host: &str,
_remote_port: u16,
) -> Option<PortForward> {
None
}

pub async fn is_port_forwarded(&self, _port: u16) -> bool {
false
}
}

pub async fn list_remote_listening_ports(
_manager: &SSHConnectionManager,
_connection_id: &str,
) -> anyhow::Result<Vec<RemoteListeningPort>> {
Err(unsupported())
}

#[derive(Debug, Clone)]
pub struct RemoteFileService;

Expand Down
Loading
Loading