add ssh support
This commit is contained in:
parent
8e8ea6426d
commit
78aee7ce40
8 changed files with 1358 additions and 20 deletions
999
Cargo.lock
generated
999
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,9 @@ prost = "0.14"
|
||||||
prost-types = "0.14"
|
prost-types = "0.14"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
russh = "0.45"
|
||||||
|
russh-keys = "0.45"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
prost-build = "0.14"
|
prost-build = "0.14"
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ message MikrotikDevice {
|
||||||
string username = 3;
|
string username = 3;
|
||||||
string password = 4;
|
string password = 4;
|
||||||
bool use_ssl = 5;
|
bool use_ssl = 5;
|
||||||
|
uint32 ssh_port = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MikrotikCommand {
|
message MikrotikCommand {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod mikrotik;
|
mod mikrotik;
|
||||||
mod proto;
|
mod proto;
|
||||||
mod snmp;
|
mod snmp;
|
||||||
|
mod ssh;
|
||||||
mod version;
|
mod version;
|
||||||
mod websocket_client;
|
mod websocket_client;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ use crate::snmp::SnmpClient;
|
||||||
use crate::metrics::Timestamp;
|
use crate::metrics::Timestamp;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
|
/// Redact SNMP community string for logging, showing first 2 chars only
|
||||||
|
fn redact_community(community: &str) -> String {
|
||||||
|
let len = community.len();
|
||||||
|
if len == 0 {
|
||||||
|
return "[redacted]".to_string();
|
||||||
|
}
|
||||||
|
if len <= 2 {
|
||||||
|
"**".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}***", &community[..2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Executor handles polling individual pieces of equipment
|
/// Executor handles polling individual pieces of equipment
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Executor {
|
pub struct Executor {
|
||||||
|
|
@ -43,9 +56,10 @@ impl Executor {
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Polling {} sensors for equipment: {}",
|
"Polling {} sensors for equipment: {} (community: {})",
|
||||||
equipment.sensors.len(),
|
equipment.sensors.len(),
|
||||||
equipment.name
|
equipment.name,
|
||||||
|
redact_community(&equipment.snmp.community)
|
||||||
);
|
);
|
||||||
|
|
||||||
for sensor in &equipment.sensors {
|
for sensor in &equipment.sensors {
|
||||||
|
|
@ -113,9 +127,10 @@ impl Executor {
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Polling {} interfaces for equipment: {}",
|
"Polling {} interfaces for equipment: {} (community: {})",
|
||||||
equipment.interfaces.len(),
|
equipment.interfaces.len(),
|
||||||
equipment.name
|
equipment.name,
|
||||||
|
redact_community(&equipment.snmp.community)
|
||||||
);
|
);
|
||||||
|
|
||||||
for interface in &equipment.interfaces {
|
for interface in &equipment.interfaces {
|
||||||
|
|
@ -231,3 +246,34 @@ impl Executor {
|
||||||
.ok_or_else(|| ExecutorError::Conversion("Could not convert value to i64".into()))
|
.ok_or_else(|| ExecutorError::Conversion("Could not convert value to i64".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_normal() {
|
||||||
|
assert_eq!(redact_community("public"), "pu***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_short() {
|
||||||
|
assert_eq!(redact_community("ab"), "**");
|
||||||
|
assert_eq!(redact_community("a"), "**");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_empty() {
|
||||||
|
assert_eq!(redact_community(""), "[redacted]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_three_chars() {
|
||||||
|
assert_eq!(redact_community("abc"), "ab***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_long() {
|
||||||
|
assert_eq!(redact_community("mysecretcommunity"), "my***");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
145
src/ssh/client.rs
Normal file
145
src/ssh/client.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use russh::client;
|
||||||
|
use russh_keys::key;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum SshError {
|
||||||
|
#[error("SSH connection failed: {0}")]
|
||||||
|
ConnectionFailed(String),
|
||||||
|
#[error("SSH authentication failed")]
|
||||||
|
AuthenticationFailed,
|
||||||
|
#[error("SSH command execution failed: {0}")]
|
||||||
|
CommandFailed(String),
|
||||||
|
#[error("SSH I/O error: {0}")]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
#[error("SSH protocol error: {0}")]
|
||||||
|
Protocol(#[from] russh::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SshResult<T> = Result<T, SshError>;
|
||||||
|
|
||||||
|
struct Client;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl client::Handler for Client {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn check_server_key(
|
||||||
|
&mut self,
|
||||||
|
server_public_key: &key::PublicKey,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
// Accept any server key (similar to SSH -o StrictHostKeyChecking=no)
|
||||||
|
// In production, you might want to verify against known_hosts
|
||||||
|
let _ = server_public_key; // Suppress unused warning
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SshClient {
|
||||||
|
session: client::Handle<Client>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshClient {
|
||||||
|
/// Connect to an SSH server and authenticate with password
|
||||||
|
pub async fn connect(host: &str, port: u32, username: &str, password: &str) -> SshResult<Self> {
|
||||||
|
let config = client::Config::default();
|
||||||
|
let sh = Client;
|
||||||
|
|
||||||
|
tracing::debug!("Connecting to {}:{} as {}", host, port, username);
|
||||||
|
|
||||||
|
let mut session = client::connect(Arc::new(config), (host, port as u16), sh)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let auth_result = session
|
||||||
|
.authenticate_password(username, password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
if !auth_result {
|
||||||
|
return Err(SshError::AuthenticationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("SSH authentication successful");
|
||||||
|
|
||||||
|
Ok(Self { session })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a command and return the output as a String
|
||||||
|
pub async fn execute_command(&mut self, command: &str) -> SshResult<String> {
|
||||||
|
tracing::debug!("Executing SSH command: {}", command);
|
||||||
|
|
||||||
|
let mut channel = self
|
||||||
|
.session
|
||||||
|
.channel_open_session()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::CommandFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.exec(true, command)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::CommandFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut stderr_output = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(msg) = channel.wait().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
russh::ChannelMsg::Data { ref data } => {
|
||||||
|
output.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
russh::ChannelMsg::ExtendedData { ref data, ext: 1 } => {
|
||||||
|
// stderr
|
||||||
|
stderr_output.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
russh::ChannelMsg::ExitStatus { exit_status } => {
|
||||||
|
tracing::debug!("Command exit status: {}", exit_status);
|
||||||
|
if exit_status != 0 {
|
||||||
|
let stderr_str = String::from_utf8_lossy(&stderr_output);
|
||||||
|
return Err(SshError::CommandFailed(format!(
|
||||||
|
"Command exited with status {}: {}",
|
||||||
|
exit_status, stderr_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
russh::ChannelMsg::Eof => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel
|
||||||
|
.eof()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::CommandFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SshError::CommandFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let output_str = String::from_utf8_lossy(&output).to_string();
|
||||||
|
tracing::debug!(
|
||||||
|
"Command output: {} bytes, {} lines",
|
||||||
|
output_str.len(),
|
||||||
|
output_str.lines().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(output_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the SSH session
|
||||||
|
pub async fn close(self) -> SshResult<()> {
|
||||||
|
self.session
|
||||||
|
.disconnect(russh::Disconnect::ByApplication, "", "")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/ssh/mod.rs
Normal file
3
src/ssh/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
pub use client::SshClient;
|
||||||
|
|
@ -338,6 +338,19 @@ impl AgentClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redact SNMP community string for logging, showing first 2 chars only
|
||||||
|
fn redact_community(community: &str) -> String {
|
||||||
|
let len = community.len();
|
||||||
|
if len == 0 {
|
||||||
|
return "[redacted]".to_string();
|
||||||
|
}
|
||||||
|
if len <= 2 {
|
||||||
|
"**".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}***", &community[..2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute an SNMP job and collect results.
|
/// Execute an SNMP job and collect results.
|
||||||
async fn execute_snmp_job(
|
async fn execute_snmp_job(
|
||||||
job: AgentJob,
|
job: AgentJob,
|
||||||
|
|
@ -346,11 +359,7 @@ async fn execute_snmp_job(
|
||||||
let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?;
|
let snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?;
|
||||||
|
|
||||||
// Log SNMP connection parameters for debugging (mask community for security)
|
// Log SNMP connection parameters for debugging (mask community for security)
|
||||||
let community_masked = if snmp_device.community.len() > 4 {
|
let community_masked = redact_community(&snmp_device.community);
|
||||||
format!("{}***", &snmp_device.community[..2])
|
|
||||||
} else {
|
|
||||||
"***".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Executing SNMP job for device {} at {}:{} (community: {}, version: {})",
|
"Executing SNMP job for device {} at {}:{} (community: {}, version: {})",
|
||||||
|
|
@ -470,6 +479,20 @@ async fn execute_mikrotik_job(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use crate::mikrotik::{MikrotikClient, SecretString};
|
use crate::mikrotik::{MikrotikClient, SecretString};
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
// Check if this is a backup job (job_id starts with "backup:")
|
||||||
|
// Backup jobs use SSH instead of API because /export doesn't work via API
|
||||||
|
if job.job_id.starts_with("backup:") {
|
||||||
|
let mikrotik_device = job
|
||||||
|
.mikrotik_device
|
||||||
|
.clone()
|
||||||
|
.ok_or("Job missing MikroTik device info")?;
|
||||||
|
return execute_mikrotik_backup_via_ssh(job, mikrotik_device, result_tx, timestamp).await;
|
||||||
|
}
|
||||||
|
|
||||||
let mikrotik_device = job
|
let mikrotik_device = job
|
||||||
.mikrotik_device
|
.mikrotik_device
|
||||||
.ok_or("Job missing MikroTik device info")?;
|
.ok_or("Job missing MikroTik device info")?;
|
||||||
|
|
@ -483,10 +506,6 @@ async fn execute_mikrotik_job(
|
||||||
mikrotik_device.use_ssl
|
mikrotik_device.use_ssl
|
||||||
);
|
);
|
||||||
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
let password = SecretString::new(&mikrotik_device.password);
|
let password = SecretString::new(&mikrotik_device.password);
|
||||||
|
|
||||||
// Connect and authenticate to MikroTik RouterOS API
|
// Connect and authenticate to MikroTik RouterOS API
|
||||||
|
|
@ -587,6 +606,15 @@ async fn execute_mikrotik_job(
|
||||||
attr_keys
|
attr_keys
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log when we hit EOF during /file/read
|
||||||
|
if cmd.command == "/file/read" {
|
||||||
|
if let Some(data) = sentence.attributes.get("data") {
|
||||||
|
if data.is_empty() {
|
||||||
|
tracing::debug!("Reached end of file (empty chunk)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
all_sentences.push(MikrotikSentence {
|
all_sentences.push(MikrotikSentence {
|
||||||
attributes: sentence.attributes.clone(),
|
attributes: sentence.attributes.clone(),
|
||||||
});
|
});
|
||||||
|
|
@ -631,6 +659,106 @@ async fn execute_mikrotik_job(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute a MikroTik backup job via SSH (because /export doesn't work via API).
|
||||||
|
async fn execute_mikrotik_backup_via_ssh(
|
||||||
|
job: AgentJob,
|
||||||
|
mikrotik_device: crate::proto::agent::MikrotikDevice,
|
||||||
|
result_tx: mpsc::UnboundedSender<MikrotikResult>,
|
||||||
|
timestamp: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
use crate::ssh::SshClient;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Executing backup via SSH for device {} at {}:{} (job: {})",
|
||||||
|
job.device_id,
|
||||||
|
mikrotik_device.ip,
|
||||||
|
mikrotik_device.ssh_port,
|
||||||
|
job.job_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connect via SSH
|
||||||
|
let mut ssh_client = match SshClient::connect(
|
||||||
|
&mikrotik_device.ip,
|
||||||
|
mikrotik_device.ssh_port,
|
||||||
|
&mikrotik_device.username,
|
||||||
|
&mikrotik_device.password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("SSH connection failed: {}", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
let result = MikrotikResult {
|
||||||
|
device_id: job.device_id,
|
||||||
|
job_id: job.job_id,
|
||||||
|
sentences: vec![],
|
||||||
|
error: error_msg,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
let _ = result_tx.send(result);
|
||||||
|
return Err(format!("SSH connection failed: {}", e).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute /export compact command
|
||||||
|
let config = match ssh_client.execute_command("/export compact").await {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("SSH command failed: {}", e);
|
||||||
|
tracing::error!("{}", error_msg);
|
||||||
|
let result = MikrotikResult {
|
||||||
|
device_id: job.device_id,
|
||||||
|
job_id: job.job_id,
|
||||||
|
sentences: vec![],
|
||||||
|
error: error_msg,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
let _ = result_tx.send(result);
|
||||||
|
let _ = ssh_client.close().await;
|
||||||
|
return Err(format!("SSH command failed: {}", e).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close SSH connection
|
||||||
|
let _ = ssh_client.close().await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Backup completed: {} bytes, {} lines",
|
||||||
|
config.len(),
|
||||||
|
config.lines().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the config as a single sentence with "config" attribute
|
||||||
|
let mut attributes = std::collections::HashMap::new();
|
||||||
|
attributes.insert("config".to_string(), config);
|
||||||
|
|
||||||
|
let job_id_for_log = job.job_id.clone();
|
||||||
|
|
||||||
|
let result = MikrotikResult {
|
||||||
|
device_id: job.device_id,
|
||||||
|
job_id: job.job_id,
|
||||||
|
sentences: vec![MikrotikSentence { attributes }],
|
||||||
|
error: String::new(),
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"MikroTik backup job {} completed successfully",
|
||||||
|
result.job_id
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = result_tx.send(result) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to send MikroTik backup result for job {}: channel closed",
|
||||||
|
job_id_for_log
|
||||||
|
);
|
||||||
|
return Err(format!("Result channel closed: {}", e).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert SnmpValue to String for protobuf transmission.
|
/// Convert SnmpValue to String for protobuf transmission.
|
||||||
fn value_to_string(value: SnmpValue) -> String {
|
fn value_to_string(value: SnmpValue) -> String {
|
||||||
match value {
|
match value {
|
||||||
|
|
@ -908,4 +1036,30 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: AgentClient methods require WebSocket connection and are tested via integration tests
|
// Note: AgentClient methods require WebSocket connection and are tested via integration tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_normal() {
|
||||||
|
assert_eq!(redact_community("public"), "pu***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_short() {
|
||||||
|
assert_eq!(redact_community("ab"), "**");
|
||||||
|
assert_eq!(redact_community("a"), "**");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_empty() {
|
||||||
|
assert_eq!(redact_community(""), "[redacted]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_three_chars() {
|
||||||
|
assert_eq!(redact_community("abc"), "ab***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_community_long() {
|
||||||
|
assert_eq!(redact_community("mysecretcommunity"), "my***");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue