use crate::{
DriverCallback, DriverCategory, DriverContext, DriverError, DriverResult,
types::{Driver, DriverParameter},
};
use serde_json::{Value, json};
use ssh2::Session;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info};
#[derive(Debug)]
pub struct SshExecDriver;
#[async_trait::async_trait]
impl Driver for SshExecDriver {
fn name(&self) -> &str {
return "ssh_exec";
}
fn description(&self) -> &str {
return "Execute a command on a remote host via SSH";
}
fn usage_hint(&self) -> &str {
return "Use this skill when you need to run commands on a remote server via SSH. Requires authentication (password or key).";
}
fn parameters(&self) -> Vec<DriverParameter> {
return vec![
DriverParameter {
name: "host".to_string(),
param_type: "string".to_string(),
description: "Remote hostname or IP address".to_string(),
required: true,
default: None,
example: Some(Value::String("192.168.1.100".to_string())),
enum_values: None,
},
DriverParameter {
name: "port".to_string(),
param_type: "integer".to_string(),
description: "SSH port (default: 22)".to_string(),
required: false,
default: Some(Value::Number(22.into())),
example: Some(Value::Number(2222.into())),
enum_values: None,
},
DriverParameter {
name: "username".to_string(),
param_type: "string".to_string(),
description: "SSH username".to_string(),
required: true,
default: None,
example: Some(Value::String("root".to_string())),
enum_values: None,
},
DriverParameter {
name: "password".to_string(),
param_type: "string".to_string(),
description: "SSH password (optional if key provided)".to_string(),
required: false,
default: None,
example: Some(Value::String("secret123".to_string())),
enum_values: None,
},
DriverParameter {
name: "key_path".to_string(),
param_type: "string".to_string(),
description: "Path to SSH private key (optional if password provided)".to_string(),
required: false,
default: None,
example: Some(Value::String("/home/user/.ssh/id_rsa".to_string())),
enum_values: None,
},
DriverParameter {
name: "command".to_string(),
param_type: "string".to_string(),
description: "Command to execute on remote host".to_string(),
required: true,
default: None,
example: Some(Value::String("ls -la /var/log".to_string())),
enum_values: None,
},
DriverParameter {
name: "timeout".to_string(),
param_type: "integer".to_string(),
description: "Connection and execution timeout in seconds (default: 30)".to_string(),
required: false,
default: Some(Value::Number(30.into())),
example: Some(Value::Number(60.into())),
enum_values: None,
},
];
}
fn example_call(&self) -> DriverResult<Value> {
return Ok(json!({
"action": "ssh_exec",
"parameters": {
"host": "192.168.1.100",
"username": "root",
"password": "secret123",
"command": "uptime"
}
}));
}
fn example_output(&self) -> String {
return "Command executed successfully (exit code: 0)\nstdout: 10:30:00 up 5 days, 2 users, load average: 0.5\nstderr: ".to_string();
}
fn category(&self) -> DriverCategory {
return DriverCategory::Network;
}
async fn execute(
&self,
parameters: &HashMap<String, Value>,
_callback: Option<&dyn DriverCallback>,
_context: Option<&DriverContext>,
) -> DriverResult<String> {
debug!("Executing ssh_exec driver");
let host = parameters
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| {
debug!("Missing 'host' parameter");
return DriverError::missing_parameter("host");
})?
.to_string();
let port = parameters.get("port").and_then(|v| v.as_u64()).unwrap_or(22) as u16;
let username = parameters
.get("username")
.and_then(|v| v.as_str())
.ok_or_else(|| {
debug!("Missing 'username' parameter");
return DriverError::missing_parameter("username");
})?
.to_string();
let password = parameters.get("password").and_then(|v| v.as_str()).map(|s| s.to_string());
let key_path = parameters.get("key_path").and_then(|v| v.as_str()).map(|s| s.to_string());
let command = parameters
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| {
debug!("Missing 'command' parameter");
return DriverError::missing_parameter("command");
})?
.to_string();
let timeout_secs = parameters.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30);
info!("SSH exec: host={}, port={}, username={}, timeout={}s", host, port, username, timeout_secs);
if password.is_none() && key_path.is_none() {
debug!("Either password or key_path must be provided");
return Err(DriverError::execution("Either password or key_path must be provided"));
}
let result = tokio::task::spawn_blocking(move || {
let timeout_dur = Duration::from_secs(timeout_secs);
let addr = format!("{}:{}", host, port);
let stream = TcpStream::connect_timeout(&addr.parse().map_err(|e| format!("Invalid address: {}", e))?, timeout_dur)
.map_err(|e| format!("Failed to connect to {}: {}", addr, e))?;
let mut session = Session::new().map_err(|e| format!("Failed to create SSH session: {}", e))?;
session.set_tcp_stream(stream);
session.handshake().map_err(|e| format!("SSH handshake failed: {}", e))?;
if let Some(pass) = password.as_deref() {
session.userauth_password(&username, pass).map_err(|e| format!("Password authentication failed: {}", e))?;
} else if let Some(key_path_str) = key_path.as_deref() {
let key_path = Path::new(key_path_str);
if !key_path.exists() {
return Err(format!("Private key file not found: {}", key_path_str));
}
session.userauth_pubkey_file(&username, None, key_path, None).map_err(|e| format!("Public key authentication failed: {}", e))?;
} else {
return Err("No authentication method provided".to_string());
}
if !session.authenticated() {
return Err("Authentication failed".to_string());
}
let mut channel = session.channel_session().map_err(|e| format!("Failed to open channel: {}", e))?;
channel.exec(&command).map_err(|e| format!("Failed to execute command: {}", e))?;
let mut stdout = String::new();
let mut stdout_buf = [0u8; 4096];
loop {
match channel.read(&mut stdout_buf) {
Ok(0) => break,
Ok(n) => {
stdout.push_str(&String::from_utf8_lossy(&stdout_buf[..n]));
}
Err(_) => break,
}
}
let mut stderr = String::new();
let mut stderr_buf = [0u8; 4096];
let mut stderr_channel = channel.stderr();
loop {
match stderr_channel.read(&mut stderr_buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
stderr.push_str(&String::from_utf8_lossy(&stderr_buf[..n]));
}
}
}
let exit_code = channel.exit_status().map_err(|e| format!("Failed to get exit status: {}", e))?;
channel.close().map_err(|e| format!("Failed to close channel: {}", e))?;
session.disconnect(None, "Goodbye", None).map_err(|e| format!("Failed to disconnect: {}", e))?;
return Ok::<_, String>((exit_code, stdout, stderr));
})
.await
.map_err(|e| {
debug!("Task panicked: {}", e);
return DriverError::execution(format!("Task panicked: {}", e));
})?
.map_err(|e| {
debug!("SSH execution failed: {}", e);
return DriverError::execution(e);
})?;
let (exit_code, stdout, stderr) = result;
info!("SSH exec complete: exit_code={}, stdout_len={}, stderr_len={}", exit_code, stdout.len(), stderr.len());
return Ok(format!(
"Command executed successfully (exit code: {})\nstdout: {}\nstderr: {}",
exit_code,
if stdout.is_empty() { "(empty)" } else { &stdout },
if stderr.is_empty() { "(empty)" } else { &stderr }
));
}
}