#[cfg(test)]
#[path = "ssh_tests.rs"]
mod ssh_tests;
use crate::traits::{LoaderInfo, LoaderType, LogLoader, Result, ScoutyError};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq)]
pub struct SshUrl {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
pub path: String,
}
impl SshUrl {
pub fn parse(url: &str) -> std::result::Result<Self, String> {
let rest = url
.strip_prefix("ssh://")
.ok_or_else(|| "URL must start with ssh://".to_string())?;
if rest.is_empty() {
return Err("Empty SSH URL".to_string());
}
let colon_slash_pos = rest
.find(":/")
.ok_or_else(|| "Missing ':/' separator between host and path".to_string())?;
let host_part = &rest[..colon_slash_pos];
let path = &rest[colon_slash_pos + 1..];
if path.is_empty() || !path.starts_with('/') {
return Err("Path must be absolute (start with /)".to_string());
}
let (user, host_and_port) = if let Some(at_pos) = host_part.find('@') {
let user = &host_part[..at_pos];
if user.is_empty() {
return Err("Empty username in SSH URL".to_string());
}
(Some(user.to_string()), &host_part[at_pos + 1..])
} else {
(None, host_part)
};
let (host, port) = if let Some(colon_pos) = host_and_port.rfind(':') {
let port_str = &host_and_port[colon_pos + 1..];
match port_str.parse::<u16>() {
Ok(p) => (host_and_port[..colon_pos].to_string(), Some(p)),
Err(_) => {
(host_and_port.to_string(), None)
}
}
} else {
(host_and_port.to_string(), None)
};
if host.is_empty() {
return Err("Empty hostname in SSH URL".to_string());
}
Ok(SshUrl {
user,
host,
port,
path: path.to_string(),
})
}
pub fn to_url_string(&self) -> String {
let mut s = "ssh://".to_string();
if let Some(ref user) = self.user {
s.push_str(user);
s.push('@');
}
s.push_str(&self.host);
if let Some(port) = self.port {
s.push(':');
s.push_str(&port.to_string());
}
s.push(':');
s.push_str(&self.path);
s
}
fn ssh_destination(&self) -> String {
match &self.user {
Some(user) => format!("{}@{}", user, self.host),
None => self.host.clone(),
}
}
}
pub fn is_ssh_url(s: &str) -> bool {
s.starts_with("ssh://")
}
#[derive(Debug)]
pub struct SshLoader {
url: SshUrl,
info: LoaderInfo,
connect_timeout: u32,
keepalive_interval: u32,
}
impl SshLoader {
pub fn new(url: SshUrl, connect_timeout: u32, keepalive_interval: u32) -> Self {
let id = url.to_url_string();
Self {
info: LoaderInfo {
id,
loader_type: LoaderType::TextFile,
multiline_enabled: false,
sample_lines: Vec::new(),
file_mod_year: None,
},
url,
connect_timeout,
keepalive_interval,
}
}
}
impl LogLoader for SshLoader {
fn info(&self) -> &LoaderInfo {
&self.info
}
fn load(&mut self) -> Result<Vec<String>> {
let mut cmd = Command::new("ssh");
cmd.arg("-o")
.arg(format!("ConnectTimeout={}", self.connect_timeout));
cmd.arg("-o").arg("BatchMode=yes");
if self.keepalive_interval > 0 {
cmd.arg("-o")
.arg(format!("ServerAliveInterval={}", self.keepalive_interval));
cmd.arg("-o").arg("ServerAliveCountMax=3");
}
if let Some(port) = self.url.port {
cmd.arg("-p").arg(port.to_string());
}
cmd.arg(self.url.ssh_destination());
cmd.arg(format!("cat {}", shell_escape(&self.url.path)));
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| {
ScoutyError::Io(std::io::Error::new(
e.kind(),
format!(
"SSH: Failed to spawn ssh command for {}: {}",
self.url.to_url_string(),
e
),
))
})?;
let output = child.wait_with_output().map_err(|e| {
ScoutyError::Io(std::io::Error::new(
e.kind(),
format!(
"SSH: Failed to read output from {}: {}",
self.url.to_url_string(),
e
),
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = if stderr.contains("Connection refused") {
format!(
"SSH: Connection refused to {}:{}",
self.url.host,
self.url.port.unwrap_or(22)
)
} else if stderr.contains("Permission denied") {
format!(
"SSH: Permission denied for {} (key-based auth required)",
self.url.ssh_destination()
)
} else if stderr.contains("Could not resolve hostname") {
format!("SSH: Could not resolve hostname '{}'", self.url.host)
} else if stderr.contains("No such file") {
format!("SSH: Remote file not found: {}", self.url.path)
} else {
format!(
"SSH: Command failed for {}: {}",
self.url.to_url_string(),
stderr.trim()
)
};
return Err(ScoutyError::Io(std::io::Error::other(msg)));
}
let content = String::from_utf8(output.stdout).map_err(|e| {
ScoutyError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"SSH: Remote file '{}' contains invalid UTF-8: {}",
self.url.path, e
),
))
})?;
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
self.info.sample_lines = lines.iter().take(10).cloned().collect();
Ok(lines)
}
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}