05886022e0
Opción B: RemoteEngine orquesta en un host remoto tunelando el wire
del daemon sobre un canal SSH direct-streamlocal hacia el sandokan.sock
remoto. El protocolo es idéntico al de DaemonEngine (postcard
length-prefixed) — sólo cambia el transporte, así que read_frame/
write_frame se reusan tal cual.
- brahman-ssh-multiplex: + SshSession::forward_unix — abre un canal
direct-streamlocal y devuelve su ChannelStream (AsyncRead+AsyncWrite).
- sandokan-daemon: protocol ahora pub, exporta read_frame/write_frame.
- sandokan-remote: RemoteEngine { SshSession + remote_socket }.
connect() o with_session(); cada operación abre un canal nuevo
(multiplexado sobre la conexión maestra).
- sandokan umbrella re-exporta RemoteEngine.
Completa Fase B: sandokan tiene Local + Daemon + Remote + auto().
cargo check --workspace verde. RemoteEngine necesita un host remoto
con `sandokan daemon` para validación runtime (sin unit test).
Opción A (text-parse del CLI por compat) queda pendiente por decisión
del usuario.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
210 lines
6.6 KiB
Rust
210 lines
6.6 KiB
Rust
//! `brahman-ssh-multiplex` — sesión SSH maestra con canales multiplexados.
|
|
//!
|
|
//! SSH ya multiplexa canales sobre una sola conexión TCP por diseño del
|
|
//! protocolo. Este crate envuelve `russh` con una API mínima: una
|
|
//! `SshSession` mantiene el `Handle` maestro; cada `exec` concurrente
|
|
//! abre su propio canal en paralelo sobre la misma conexión.
|
|
//!
|
|
//! Lo consumen `sandokan::RemoteEngine` y el `Linker` SSH de `matilda`.
|
|
//!
|
|
//! Verificación de host key: TOFU por default (acepta la primera vez).
|
|
//! Pasá un fingerprint esperado para verificación estricta.
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
/// Método de autenticación.
|
|
#[derive(Debug, Clone)]
|
|
pub enum SshAuth {
|
|
/// Password en claro.
|
|
Password(String),
|
|
/// Clave privada en disco, con passphrase opcional.
|
|
Key {
|
|
path: PathBuf,
|
|
passphrase: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Configuración de conexión.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SshConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub user: String,
|
|
pub auth: SshAuth,
|
|
/// Intervalo de keepalive en segundos (0 = usar default de russh).
|
|
pub keepalive_secs: u64,
|
|
}
|
|
|
|
impl SshConfig {
|
|
/// Config con puerto 22 y keepalive de 15s.
|
|
pub fn new(host: impl Into<String>, user: impl Into<String>, auth: SshAuth) -> Self {
|
|
Self {
|
|
host: host.into(),
|
|
port: 22,
|
|
user: user.into(),
|
|
auth,
|
|
keepalive_secs: 15,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Falla de una operación SSH.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SshError {
|
|
#[error("conexión SSH: {0}")]
|
|
Connect(String),
|
|
#[error("autenticación SSH rechazada")]
|
|
AuthRejected,
|
|
#[error("clave privada: {0}")]
|
|
Key(String),
|
|
#[error("canal SSH: {0}")]
|
|
Channel(String),
|
|
}
|
|
|
|
/// Salida de un comando remoto.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExecOutput {
|
|
pub stdout: Vec<u8>,
|
|
pub stderr: Vec<u8>,
|
|
pub exit_code: i32,
|
|
}
|
|
|
|
/// Handler de cliente russh — verificación de host key.
|
|
struct ClientHandler {
|
|
/// Fingerprint esperado; `None` = TOFU (acepta cualquiera).
|
|
expected: Option<Vec<u8>>,
|
|
}
|
|
|
|
impl russh::client::Handler for ClientHandler {
|
|
type Error = russh::Error;
|
|
|
|
async fn check_server_key(
|
|
&mut self,
|
|
server_public_key: &russh::keys::ssh_key::PublicKey,
|
|
) -> Result<bool, Self::Error> {
|
|
match &self.expected {
|
|
None => Ok(true),
|
|
Some(exp) => Ok(server_public_key.to_bytes().map(|b| &b == exp).unwrap_or(false)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sesión SSH. `Clone` barato — comparte el `Handle` maestro; clones
|
|
/// abren canales paralelos sobre la misma conexión.
|
|
#[derive(Clone)]
|
|
pub struct SshSession {
|
|
handle: Arc<russh::client::Handle<ClientHandler>>,
|
|
}
|
|
|
|
impl SshSession {
|
|
/// Conecta y autentica contra el host.
|
|
pub async fn connect(config: &SshConfig) -> Result<Self, SshError> {
|
|
let mut russh_cfg = russh::client::Config::default();
|
|
if config.keepalive_secs > 0 {
|
|
russh_cfg.keepalive_interval = Some(Duration::from_secs(config.keepalive_secs));
|
|
}
|
|
let russh_cfg = Arc::new(russh_cfg);
|
|
let handler = ClientHandler { expected: None };
|
|
|
|
let mut handle = russh::client::connect(
|
|
russh_cfg,
|
|
(config.host.as_str(), config.port),
|
|
handler,
|
|
)
|
|
.await
|
|
.map_err(|e| SshError::Connect(e.to_string()))?;
|
|
|
|
let ok = match &config.auth {
|
|
SshAuth::Password(pw) => handle
|
|
.authenticate_password(&config.user, pw)
|
|
.await
|
|
.map_err(|e| SshError::Connect(e.to_string()))?
|
|
.success(),
|
|
SshAuth::Key { path, passphrase } => {
|
|
let key = russh::keys::load_secret_key(path, passphrase.as_deref())
|
|
.map_err(|e| SshError::Key(e.to_string()))?;
|
|
let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), None);
|
|
handle
|
|
.authenticate_publickey(&config.user, key)
|
|
.await
|
|
.map_err(|e| SshError::Connect(e.to_string()))?
|
|
.success()
|
|
}
|
|
};
|
|
if !ok {
|
|
return Err(SshError::AuthRejected);
|
|
}
|
|
Ok(Self { handle: Arc::new(handle) })
|
|
}
|
|
|
|
/// Abre un canal `direct-streamlocal` hacia un Unix socket del host
|
|
/// remoto y devuelve su stream bidireccional (`AsyncRead + AsyncWrite`).
|
|
///
|
|
/// Permite tunelar un protocolo arbitrario (p. ej. el wire postcard
|
|
/// de `sandokan-daemon`) contra un socket remoto, reusando el código
|
|
/// de cliente sin cambios — sólo cambia el transporte.
|
|
pub async fn forward_unix(
|
|
&self,
|
|
remote_socket: &str,
|
|
) -> Result<russh::ChannelStream<russh::client::Msg>, SshError> {
|
|
let channel = self
|
|
.handle
|
|
.channel_open_direct_streamlocal(remote_socket)
|
|
.await
|
|
.map_err(|e| SshError::Channel(e.to_string()))?;
|
|
Ok(channel.into_stream())
|
|
}
|
|
|
|
/// Ejecuta `command` en un canal nuevo y junta su salida completa.
|
|
/// Canales concurrentes se multiplexan sobre la misma conexión.
|
|
pub async fn exec(&self, command: &str) -> Result<ExecOutput, SshError> {
|
|
let mut channel = self
|
|
.handle
|
|
.channel_open_session()
|
|
.await
|
|
.map_err(|e| SshError::Channel(e.to_string()))?;
|
|
channel
|
|
.exec(true, command)
|
|
.await
|
|
.map_err(|e| SshError::Channel(e.to_string()))?;
|
|
|
|
let mut out = ExecOutput {
|
|
stdout: Vec::new(),
|
|
stderr: Vec::new(),
|
|
exit_code: -1,
|
|
};
|
|
while let Some(msg) = channel.wait().await {
|
|
match msg {
|
|
russh::ChannelMsg::Data { ref data } => out.stdout.extend_from_slice(data),
|
|
russh::ChannelMsg::ExtendedData { ref data, .. } => {
|
|
out.stderr.extend_from_slice(data)
|
|
}
|
|
russh::ChannelMsg::ExitStatus { exit_status } => {
|
|
out.exit_code = exit_status as i32
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn config_defaults_port_and_keepalive() {
|
|
let c = SshConfig::new("host.example", "user", SshAuth::Password("x".into()));
|
|
assert_eq!(c.port, 22);
|
|
assert_eq!(c.keepalive_secs, 15);
|
|
}
|
|
|
|
// El test de conexión real necesita un servidor SSH — se hace fuera
|
|
// del unit test (ver instrucciones de prueba de matilda/sandokan).
|
|
}
|