From fb5a02821ecaec7ebb5ff59284830c788c250ea6 Mon Sep 17 00:00:00 2001 From: Tom You Date: Thu, 9 Jul 2026 00:20:43 -0500 Subject: [PATCH] feat(runtime): add SSH command bridge --- rabby-runtime/src/lib.rs | 1 + rabby-runtime/src/ssh_runtime/mod.rs | 152 +++++++++++++++++++++++++++ src-tauri/src/main.rs | 28 +++++ ui/app.js | 22 ++++ ui/index.html | 16 +++ ui/styles.css | 6 ++ 6 files changed, 225 insertions(+) create mode 100644 rabby-runtime/src/ssh_runtime/mod.rs diff --git a/rabby-runtime/src/lib.rs b/rabby-runtime/src/lib.rs index 6241225..43ec6d2 100644 --- a/rabby-runtime/src/lib.rs +++ b/rabby-runtime/src/lib.rs @@ -1,4 +1,5 @@ pub mod config_store; pub mod local_terminal; pub mod pty; +pub mod ssh_runtime; pub mod wallet_vault; diff --git a/rabby-runtime/src/ssh_runtime/mod.rs b/rabby-runtime/src/ssh_runtime/mod.rs new file mode 100644 index 0000000..ad12771 --- /dev/null +++ b/rabby-runtime/src/ssh_runtime/mod.rs @@ -0,0 +1,152 @@ +use serde::Serialize; +use std::process::Command; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshCommand { + pub host: String, + pub user: Option, + pub port: u16, + pub command: String, + pub timeout: Duration, +} + +impl SshCommand { + pub fn new(host: impl Into, command: impl Into) -> Self { + Self { + host: host.into(), + user: None, + port: 22, + command: command.into(), + timeout: Duration::from_secs(10), + } + } + + pub fn user(mut self, user: impl Into) -> Self { + self.user = Some(user.into()); + self + } + + pub fn port(mut self, port: u16) -> Self { + self.port = port; + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn destination(&self) -> String { + match &self.user { + Some(user) if !user.trim().is_empty() => { + format!("{}@{}", user.trim(), self.host.trim()) + } + _ => self.host.trim().to_string(), + } + } + + pub fn validate(&self) -> Result<(), SshError> { + if self.host.trim().is_empty() { + return Err(SshError::Invalid("ssh host is required".to_string())); + } + if self.command.trim().is_empty() { + return Err(SshError::Invalid("ssh command is required".to_string())); + } + if self.port == 0 { + return Err(SshError::Invalid("ssh port is required".to_string())); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SshCommandOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshError { + Invalid(String), + Io(String), +} + +#[derive(Debug, Clone, Default)] +pub struct SshClient; + +impl SshClient { + pub fn build_args(&self, request: &SshCommand) -> Result, SshError> { + request.validate()?; + Ok(vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + format!("ConnectTimeout={}", request.timeout.as_secs().max(1)), + "-p".to_string(), + request.port.to_string(), + request.destination(), + request.command.clone(), + ]) + } + + pub fn run(&self, request: SshCommand) -> Result { + let args = self.build_args(&request)?; + let output = Command::new("ssh") + .args(args) + .output() + .map_err(|err| SshError::Io(err.to_string()))?; + Ok(SshCommandOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_command_builds_batch_mode_args() { + let request = SshCommand::new("example.com", "uptime") + .user("alice") + .port(2222) + .timeout(Duration::from_secs(3)); + let args = SshClient::default().build_args(&request).unwrap(); + + assert_eq!( + args, + vec![ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=3", + "-p", + "2222", + "alice@example.com", + "uptime" + ] + ); + } + + #[test] + fn ssh_command_rejects_missing_host_or_command() { + assert_eq!( + SshCommand::new(" ", "uptime").validate().unwrap_err(), + SshError::Invalid("ssh host is required".to_string()) + ); + assert_eq!( + SshCommand::new("example.com", " ").validate().unwrap_err(), + SshError::Invalid("ssh command is required".to_string()) + ); + } + + #[test] + fn ssh_command_destination_omits_blank_user() { + let request = SshCommand::new("host.local", "true").user(" "); + assert_eq!(request.destination(), "host.local"); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7ee25b5..ca624f8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use rabby_core::{ }; use rabby_runtime::config_store::{ConfigStore, JsonFileConfigStore}; use rabby_runtime::local_terminal::{LocalCommand, LocalCommandOutput, LocalTerminal}; +use rabby_runtime::ssh_runtime::{SshClient, SshCommand, SshCommandOutput}; use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus}; use serde::Serialize; use std::path::PathBuf; @@ -72,6 +73,25 @@ fn run_local_command(command: String, cwd: Option) -> Result, + port: Option, + command: String, +) -> Result { + let mut request = SshCommand::new(host, command); + if let Some(user) = user { + request = request.user(user); + } + if let Some(port) = port { + request = request.port(port); + } + SshClient::default() + .run(request) + .map_err(|err| format!("{err:?}")) +} + fn resolve_vault_path(path: Option) -> PathBuf { path.map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json")) @@ -111,6 +131,7 @@ fn main() { save_demo_wallet_vault, load_wallet_vault, run_local_command, + run_ssh_command, load_app_config, save_app_config ]) @@ -144,6 +165,13 @@ mod tests { assert_eq!(output.stdout, "tauri-local"); } + #[test] + fn ssh_command_bridge_rejects_blank_host() { + let err = + run_ssh_command(" ".to_string(), None, Some(22), "uptime".to_string()).unwrap_err(); + assert!(err.contains("ssh host is required")); + } + #[test] fn config_path_has_stable_file_name() { let path = resolve_config_path(None); diff --git a/ui/app.js b/ui/app.js index 5574acf..7fc92c4 100644 --- a/ui/app.js +++ b/ui/app.js @@ -185,6 +185,27 @@ function wireTerminalActions() { if (button) button.addEventListener('click', runLocalTerminalCommand); } +async function runSshCommand() { + const host = document.getElementById('ssh-host').value; + const user = document.getElementById('ssh-user').value || null; + const command = document.getElementById('ssh-command').value; + const output = document.getElementById('ssh-output'); + output.textContent = 'Connecting…'; + try { + const tauri = window.__TAURI__?.core; + if (!tauri) throw new Error('Tauri bridge unavailable in browser preview'); + const result = await tauri.invoke('run_ssh_command', { host, user, port: 22, command }); + output.textContent = [`ssh ${host} ${command}`, `exit ${result.exit_code}`, result.stdout, result.stderr].filter(Boolean).join('\n'); + } catch (error) { + output.textContent = String(error); + } +} + +function wireSshActions() { + const button = document.getElementById('run-ssh'); + if (button) button.addEventListener('click', runSshCommand); +} + async function boot() { const [dashboard, features] = await Promise.all([ invokeOrFallback('wallet_dashboard', fallbackDashboard), @@ -195,6 +216,7 @@ async function boot() { wireVaultActions(); wireSettingsActions(); wireTerminalActions(); + wireSshActions(); } boot(); diff --git a/ui/index.html b/ui/index.html index 2c0f4b6..dff324b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -159,6 +159,22 @@ +
+
+
+

SSH

+

Run a remote command

+
+ +
+
+ + + +
+
SSH runtime is wired through system ssh with BatchMode and timeout.
+
+
diff --git a/ui/styles.css b/ui/styles.css index 35eb59f..d994a2d 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -317,3 +317,9 @@ h3 { margin-bottom: 0; font-size: 18px; } .settings-form { margin-top: 18px; padding-top: 16px; border-top: 1px solid #edf1fb; display: grid; gap: 8px; } .settings-form label { color: #62708f; font-weight: 800; } .settings-form select { flex: 1; min-width: 0; border: 1px solid #dfe6f6; border-radius: 14px; padding: 11px 12px; color: #253052; background: #f8faff; } + + +.ssh-grid { margin-top: 18px; display: grid; grid-template-columns: 1fr 0.7fr 1.2fr; gap: 10px; } +.ssh-grid input { min-width: 0; border: 1px solid #dfe6f6; border-radius: 14px; padding: 12px 14px; color: #253052; background: #f8faff; } +.runtime-output { min-height: 100px; margin: 12px 0 0; padding: 16px; border-radius: 18px; color: #b9fbcf; background: #12182b; white-space: pre-wrap; overflow: auto; font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } +@media (max-width: 720px) { .ssh-grid { grid-template-columns: 1fr; } }