Files
brahman/crates/protocol/brahman-ssh-multiplex/src/lib.rs
T
sergio 05886022e0 feat(sandokan-remote): B1.4 — RemoteEngine vía SSH socket-forward
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>
2026-05-20 15:37:11 +00:00

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).
}