Présentation
wssh est un client en ligne de commande écrit en Rust. Il établit une session SSH vers un pod Kubernetes en encapsulant le trafic dans une connexion WebSocket, évitant ainsi le recours à un terminal web embarqué dans le navigateur.
Certaines plateformes de déploiement exposent un terminal web basé sur xterm.js : le navigateur ouvre un WebSocket vers un serveur intermédiaire, et ce dernier relaie les données vers le SSH du pod. wssh remplace simplement cette interface web par un programme terminal local.
Architecture
Le flux de données est le suivant :
- Le client CLI demande la liste des applications via l'API de la plateforme.
- L'utilisateur choisit une application dans une liste interactive.
- Le client ouvre un WebSocket sécurisé (wss) vers le serveur de la plateforme.
- Le serveur redirige le flux vers la connexion SSH du pod cible.
- Les frappes du clavier local et la sortie du pod transitent en temps réel.
Le client doit donc gérer trois aspects : le parsing d'arguments, l'authentification par cookie, la sélection interactive et le pontage du WebSocket vers l'entrée/sortie standard.
Implémentation
Analyse des arguments
Le choix de l'environnement est récupéré avec clap via une approche dérivée :
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "wssh", version = "0.2.0", about = "Client SSH over WebSocket")]
struct Configuration {
#[arg(short, long, value_name = "ENV")]
environnement: String,
}
fn main() {
let params = Configuration::parse();
println!("Environnement sélectionné : {}", params.environnement);
}
Authentification par cookie
Pour éviter une nouvelle saisie de mot de passe, le programme lit les cookies de session du navigateur avec la crate rookie :
let domaines = vec!["deploy.exemple.com".to_string()];
let cookies = rookie::chrome(Some(domaines))
.map_err(|e| anyhow::anyhow!("Impossible de lire les cookies : {}", e))?;
let mut valeur_cookie = String::new();
for c in cookies {
if c.name == "sessionid" || c.name == "DEPLOY_SESSID" {
valeur_cookie.push_str(&format!("{}={}; ", c.name, c.value));
}
}
Sélection interactive
La crate dialoguer fournit un sélecteur flou qui améliore l'expérience utilisateur :
use dialoguer::{theme::ColorfulTheme, FuzzySelect};
let labels: Vec<String> = applications
.iter()
.map(|a| format!("{} ({})", a.nom, a.namespace))
.collect();
let index = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Application cible")
.item("0. Quitter")
.items(&labels)
.default(0)
.interact()
.map_err(|e| anyhow::anyhow!("Échec de la sélection : {}", e))?;
Connexion WebSocket asynchrone
La partie délicate consiste à relier simultanément l'entrée utilisateur et la sortie du pod. Avec une approche synchrone, le type WebSocket requiert un accès &mut self et ne peut être partagé entre un lecteur et un écrivain. La solution consiste à utiliser tokio_tungstenite et la méthode split() :
use futures::{SinkExt, StreamExt};
let url = format!(
"wss://deploy.exemple.com/ssh?token={}",
urlencoding::encode(&token_ssh)
);
let (ws, _response) = tokio_tungstenite::connect_async(url)
.await
.map_err(|e| anyhow::anyhow!("Connexion WebSocket échouée : {}", e))?;
let (mut ecriture, mut lecture) = ws.split();
// Tâche d'envoi des frappes clavier vers le serveur
tokio::spawn(async move {
let stdin = tokio::io::stdin();
let mut buffer = [0u8; 1024];
// lecture de stdin et envoi au WebSocket
});
// Tâche de réception des sorties du pod
while let Some(message) = lecture.next().await {
if let Ok(tokio_tungstenite::tungstenite::Message::Text(texte)) = message {
println!("{}", texte);
}
}
La connnexion sous-jacente en TCP peut être scindée, mais une couche TLS synchrone ne l'est pas. L'asynchronisme de tokio_tungstenite est donc le choix naturel ici.
Gestion du terminal local
Le terminal doit être placé en mode raw afin que chaque frappe soit trnasmise immédiatement sans traitement par le shell local. On utilise pour cela crossterm :
use crossterm::terminal::{enable_raw_mode, disable_raw_mode};
enable_raw_mode()?;
// après la fin de la session
disable_raw_mode()?;
Enfin, les changements de taille de la fenêtre locale doivent être transmis au serveur pour conserver un affichage correct, notamment pour les outils interactifs :
let (colonnes, lignes) = crossterm::terminal::size()?;
let message = format!(
r#"{{"type":"resize","cols":{},"rows":{}}}"#,
colonnes, lignes
);
ecriture
.send(tokio_tungstenite::tungstenite::Message::Text(message))
.await?;
Avec le mode raw activé et les événements de redimensionnement relayés, le client reproduit fidèlement la session du pod dans le terminal local.