This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
pub mod config_store;
|
pub mod config_store;
|
||||||
pub mod local_terminal;
|
pub mod local_terminal;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
pub mod ssh_runtime;
|
||||||
pub mod wallet_vault;
|
pub mod wallet_vault;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub port: u16,
|
||||||
|
pub command: String,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshCommand {
|
||||||
|
pub fn new(host: impl Into<String>, command: impl Into<String>) -> 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<String>) -> 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<Vec<String>, 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<SshCommandOutput, SshError> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use rabby_core::{
|
|||||||
};
|
};
|
||||||
use rabby_runtime::config_store::{ConfigStore, JsonFileConfigStore};
|
use rabby_runtime::config_store::{ConfigStore, JsonFileConfigStore};
|
||||||
use rabby_runtime::local_terminal::{LocalCommand, LocalCommandOutput, LocalTerminal};
|
use rabby_runtime::local_terminal::{LocalCommand, LocalCommandOutput, LocalTerminal};
|
||||||
|
use rabby_runtime::ssh_runtime::{SshClient, SshCommand, SshCommandOutput};
|
||||||
use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus};
|
use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -72,6 +73,25 @@ fn run_local_command(command: String, cwd: Option<String>) -> Result<LocalComman
|
|||||||
.map_err(|err| format!("{err:?}"))
|
.map_err(|err| format!("{err:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn run_ssh_command(
|
||||||
|
host: String,
|
||||||
|
user: Option<String>,
|
||||||
|
port: Option<u16>,
|
||||||
|
command: String,
|
||||||
|
) -> Result<SshCommandOutput, String> {
|
||||||
|
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<String>) -> PathBuf {
|
fn resolve_vault_path(path: Option<String>) -> PathBuf {
|
||||||
path.map(PathBuf::from)
|
path.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json"))
|
.unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json"))
|
||||||
@@ -111,6 +131,7 @@ fn main() {
|
|||||||
save_demo_wallet_vault,
|
save_demo_wallet_vault,
|
||||||
load_wallet_vault,
|
load_wallet_vault,
|
||||||
run_local_command,
|
run_local_command,
|
||||||
|
run_ssh_command,
|
||||||
load_app_config,
|
load_app_config,
|
||||||
save_app_config
|
save_app_config
|
||||||
])
|
])
|
||||||
@@ -144,6 +165,13 @@ mod tests {
|
|||||||
assert_eq!(output.stdout, "tauri-local");
|
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]
|
#[test]
|
||||||
fn config_path_has_stable_file_name() {
|
fn config_path_has_stable_file_name() {
|
||||||
let path = resolve_config_path(None);
|
let path = resolve_config_path(None);
|
||||||
|
|||||||
@@ -185,6 +185,27 @@ function wireTerminalActions() {
|
|||||||
if (button) button.addEventListener('click', runLocalTerminalCommand);
|
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() {
|
async function boot() {
|
||||||
const [dashboard, features] = await Promise.all([
|
const [dashboard, features] = await Promise.all([
|
||||||
invokeOrFallback('wallet_dashboard', fallbackDashboard),
|
invokeOrFallback('wallet_dashboard', fallbackDashboard),
|
||||||
@@ -195,6 +216,7 @@ async function boot() {
|
|||||||
wireVaultActions();
|
wireVaultActions();
|
||||||
wireSettingsActions();
|
wireSettingsActions();
|
||||||
wireTerminalActions();
|
wireTerminalActions();
|
||||||
|
wireSshActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -159,6 +159,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel terminal-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">SSH</p>
|
||||||
|
<h3>Run a remote command</h3>
|
||||||
|
</div>
|
||||||
|
<button id="run-ssh" class="primary" type="button">SSH run</button>
|
||||||
|
</div>
|
||||||
|
<div class="ssh-grid">
|
||||||
|
<input id="ssh-host" placeholder="host" value="example.com" />
|
||||||
|
<input id="ssh-user" placeholder="user optional" value="" />
|
||||||
|
<input id="ssh-command" placeholder="command" value="uptime" />
|
||||||
|
</div>
|
||||||
|
<pre id="ssh-output" class="runtime-output">SSH runtime is wired through system ssh with BatchMode and timeout.</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel feature-panel">
|
<section class="panel feature-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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 { 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 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; }
|
.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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user