⎇ 0e13c35f3edb8faf79265499d07581072bf228b2
crates/protocol/brahman-ssh-multiplex/src/lib.rs
Historia
Quién tocó qué
crudo
feat(brahman-ssh-multiplex): A6 — sesión SSH multiplexada (russh)
1//! `brahman-ssh-multiplex` — sesión SSH maestra con canales multiplexados.
2//!
3//! SSH ya multiplexa canales sobre una sola conexión TCP por diseño del
4//! protocolo. Este crate envuelve `russh` con una API mínima: una
5//! `SshSession` mantiene el `Handle` maestro; cada `exec` concurrente
6//! abre su propio canal en paralelo sobre la misma conexión.
7//!
8//! Lo consumen `sandokan::RemoteEngine` y el `Linker` SSH de `matilda`.
9//!
10//! Verificación de host key: TOFU por default (acepta la primera vez).
11//! Pasá un fingerprint esperado para verificación estricta.
12
13#![forbid(unsafe_code)]
14
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::Duration;
18
19/// Método de autenticación.
20#[derive(Debug, Clone)]
21pub enum SshAuth {
22 /// Password en claro.
23 Password(String),
24 /// Clave privada en disco, con passphrase opcional.
25 Key {
26 path: PathBuf,
27 passphrase: Option<String>,
28 },
29}
30
31/// Configuración de conexión.
32#[derive(Debug, Clone)]
33pub struct SshConfig {
34 pub host: String,
35 pub port: u16,
36 pub user: String,
37 pub auth: SshAuth,
38 /// Intervalo de keepalive en segundos (0 = usar default de russh).
39 pub keepalive_secs: u64,
40}
41
42impl SshConfig {
43 /// Config con puerto 22 y keepalive de 15s.
44 pub fn new(host: impl Into<String>, user: impl Into<String>, auth: SshAuth) -> Self {
45 Self {
46 host: host.into(),
47 port: 22,
48 user: user.into(),
49 auth,
50 keepalive_secs: 15,
51 }
52 }
53}
54
55/// Falla de una operación SSH.
56#[derive(Debug, thiserror::Error)]
57pub enum SshError {
58 #[error("conexión SSH: {0}")]
59 Connect(String),
60 #[error("autenticación SSH rechazada")]
61 AuthRejected,
62 #[error("clave privada: {0}")]
63 Key(String),
64 #[error("canal SSH: {0}")]
65 Channel(String),
66}
67
68/// Salida de un comando remoto.
69#[derive(Debug, Clone)]
70pub struct ExecOutput {
71 pub stdout: Vec<u8>,
72 pub stderr: Vec<u8>,
73 pub exit_code: i32,
74}
75
76/// Handler de cliente russh — verificación de host key.
77struct ClientHandler {
78 /// Fingerprint esperado; `None` = TOFU (acepta cualquiera).
79 expected: Option<Vec<u8>>,
80}
81
82impl russh::client::Handler for ClientHandler {
83 type Error = russh::Error;
84
85 async fn check_server_key(
86 &mut self,
87 server_public_key: &russh::keys::ssh_key::PublicKey,
88 ) -> Result<bool, Self::Error> {
89 match &self.expected {
90 None => Ok(true),
91 Some(exp) => Ok(server_public_key.to_bytes().map(|b| &b == exp).unwrap_or(false)),
92 }
93 }
94}
95
96/// Sesión SSH. `Clone` barato — comparte el `Handle` maestro; clones
97/// abren canales paralelos sobre la misma conexión.
98#[derive(Clone)]
99pub struct SshSession {
100 handle: Arc<russh::client::Handle<ClientHandler>>,
101}
102
103impl SshSession {
104 /// Conecta y autentica contra el host.
105 pub async fn connect(config: &SshConfig) -> Result<Self, SshError> {
106 let mut russh_cfg = russh::client::Config::default();
107 if config.keepalive_secs > 0 {
108 russh_cfg.keepalive_interval = Some(Duration::from_secs(config.keepalive_secs));
109 }
110 let russh_cfg = Arc::new(russh_cfg);
111 let handler = ClientHandler { expected: None };
112
113 let mut handle = russh::client::connect(
114 russh_cfg,
115 (config.host.as_str(), config.port),
116 handler,
117 )
118 .await
119 .map_err(|e| SshError::Connect(e.to_string()))?;
120
121 let ok = match &config.auth {
122 SshAuth::Password(pw) => handle
123 .authenticate_password(&config.user, pw)
124 .await
125 .map_err(|e| SshError::Connect(e.to_string()))?
126 .success(),
127 SshAuth::Key { path, passphrase } => {
128 let key = russh::keys::load_secret_key(path, passphrase.as_deref())
129 .map_err(|e| SshError::Key(e.to_string()))?;
130 let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), None);
131 handle
132 .authenticate_publickey(&config.user, key)
133 .await
134 .map_err(|e| SshError::Connect(e.to_string()))?
135 .success()
136 }
137 };
138 if !ok {
139 return Err(SshError::AuthRejected);
140 }
141 Ok(Self { handle: Arc::new(handle) })
142 }
143
144 /// Ejecuta `command` en un canal nuevo y junta su salida completa.
145 /// Canales concurrentes se multiplexan sobre la misma conexión.
146 pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshError> {
147 let mut channel = self
148 .handle
149 .channel_open_session()
150 .await
151 .map_err(|e| SshError::Channel(e.to_string()))?;
152 channel
153 .exec(true, command)
154 .await
155 .map_err(|e| SshError::Channel(e.to_string()))?;
156
157 let mut out = ExecOutput {
158 stdout: Vec::new(),
159 stderr: Vec::new(),
160 exit_code: -1,
161 };
162 while let Some(msg) = channel.wait().await {
163 match msg {
164 russh::ChannelMsg::Data { ref data } => out.stdout.extend_from_slice(data),
165 russh::ChannelMsg::ExtendedData { ref data, .. } => {
166 out.stderr.extend_from_slice(data)
167 }
168 russh::ChannelMsg::ExitStatus { exit_status } => {
169 out.exit_code = exit_status as i32
170 }
171 _ => {}
172 }
173 }
174 Ok(out)
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn config_defaults_port_and_keepalive() {
184 let c = SshConfig::new("host.example", "user", SshAuth::Password("x".into()));
185 assert_eq!(c.port, 22);
186 assert_eq!(c.keepalive_secs, 15);
187 }
188
189 // El test de conexión real necesita un servidor SSH — se hace fuera
190 // del unit test (ver instrucciones de prueba de matilda/sandokan).
191}