use anyhow::{Context, Result};
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostEntry {
pub alias: String,
pub env: String,
}
pub type HostGroup = HashMap<String, HostEntry>;
pub type ServerType = HashMap<String, HostGroup>;
pub type Region = HashMap<String, ServerType>;
pub type Environment = HashMap<String, Region>;
#[derive(Debug, Serialize, Deserialize)]
pub struct HostsConfig {
#[serde(flatten)]
pub environments: Environment,
}
impl HostsConfig {
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path()?;
if !config_path.exists() {
if let Err(e) = Self::create_default_config() {
anyhow::bail!(
"Fichier de configuration non trouvé: {}\n\
Erreur lors de la création automatique: {}\n\
Créez ce fichier manuellement avec la structure des serveurs SSH.\n\
Voir examples/hosts.json pour un exemple.",
config_path.display(),
e
);
} else {
println!(
"✅ Fichier de configuration créé automatiquement: {}",
config_path.display()
);
println!("📝 Éditez ce fichier pour ajouter vos serveurs SSH.");
}
}
if let Err(e) = Self::ensure_ssh_keys() {
eprintln!("⚠️ Avertissement concernant les clés SSH: {}", e);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Impossible de lire {}", config_path.display()))?;
let config: HostsConfig = serde_json::from_str(&content)
.with_context(|| format!("Erreur de parsing JSON dans {}", config_path.display()))?;
Ok(config)
}
pub fn get_config_path() -> Result<PathBuf> {
let home = home_dir().context("Impossible de déterminer le répertoire home")?;
Ok(home.join(".ssh").join("hosts.json"))
}
pub fn filter_hosts(
&self,
env_filter: Option<&String>,
region_filter: Option<&String>,
type_filter: Option<&String>,
) -> Vec<(String, &HostEntry)> {
let mut results = Vec::new();
for (env_name, regions) in &self.environments {
if let Some(env) = env_filter {
if env_name != env {
continue;
}
}
for (region_name, server_types) in regions {
if let Some(region) = region_filter {
if region_name != region {
continue;
}
}
for (type_name, hosts) in server_types {
if let Some(server_type) = type_filter {
if type_name != server_type {
continue;
}
}
for (host_name, host_entry) in hosts {
let full_name =
format!("{}:{}:{}:{}", env_name, region_name, type_name, host_name);
results.push((full_name, host_entry));
}
}
}
}
results
}
pub fn display_all_targets(&self) {
let mut total_targets = 0;
for (env_name, env) in &self.environments {
println!("📁 {} (--env {})", env_name, env_name);
for (region_name, region) in env {
println!(" 📂 {} (--region {})", region_name, region_name);
for (type_name, server_type) in region {
println!(" 📂 {} (--type {})", type_name, type_name);
for (server_name, host_entry) in server_type {
println!(
" 🖥️ {} → {} ({})",
server_name, host_entry.alias, host_entry.env
);
total_targets += 1;
}
}
}
println!(); }
println!("📊 Total: {} cibles disponibles", total_targets);
println!("\n💡 Exemples d'utilisation:");
println!(" xsshend upload --env Production file.txt");
println!(" xsshend upload --env Staging --region Region-A file.txt");
println!(" xsshend upload --region Region-A --type Public file.txt");
}
pub fn create_default_config() -> Result<()> {
let config_path = Self::get_config_path()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Impossible de créer le répertoire {}", parent.display())
})?;
}
let example_config = Self::get_example_config();
fs::write(&config_path, example_config)
.with_context(|| format!("Impossible d'écrire {}", config_path.display()))?;
Ok(())
}
pub fn get_example_config() -> &'static str {
include_str!("../../examples/hosts.json")
}
pub fn ensure_ssh_keys() -> Result<()> {
let home = home_dir().context("Impossible de déterminer le répertoire home")?;
let ssh_dir = home.join(".ssh");
if !ssh_dir.exists() {
fs::create_dir_all(&ssh_dir).with_context(|| {
format!("Impossible de créer le répertoire {}", ssh_dir.display())
})?;
println!("📁 Répertoire ~/.ssh créé");
}
let key_paths = [
("ed25519", ssh_dir.join("id_ed25519")),
("rsa", ssh_dir.join("id_rsa")),
("ecdsa", ssh_dir.join("id_ecdsa")),
];
for (key_type, key_path) in &key_paths {
if key_path.exists() {
println!(
"🔐 Clé SSH {} trouvée: {}",
key_type.to_uppercase(),
key_path.display()
);
return Ok(());
}
}
println!("❌ Aucune clé SSH trouvée dans ~/.ssh/");
println!("🔑 Pour utiliser xsshend, vous avez besoin d'une clé SSH privée.");
print!("Voulez-vous générer une nouvelle clé SSH Ed25519 ? (o/N): ");
io::stdout()
.flush()
.context("Impossible de vider le buffer stdout")?;
let mut response = String::new();
io::stdin()
.read_line(&mut response)
.context("Impossible de lire la réponse de l'utilisateur")?;
let response = response.trim().to_lowercase();
if response == "o" || response == "oui" || response == "y" || response == "yes" {
Self::generate_ssh_key(&ssh_dir)?;
} else {
println!("⚠️ Génération de clé SSH ignorée.");
println!("💡 Vous pouvez générer une clé manuellement avec :");
println!(" ssh-keygen -t ed25519 -C \"votre_email@example.com\"");
}
Ok(())
}
fn generate_ssh_key(ssh_dir: &Path) -> Result<()> {
let key_path = ssh_dir.join("id_ed25519");
print!("Entrez votre adresse email (optionnel, pour identifier la clé): ");
io::stdout()
.flush()
.context("Impossible de vider le buffer stdout")?;
let mut email = String::new();
io::stdin()
.read_line(&mut email)
.context("Impossible de lire l'adresse email")?;
let email = email.trim();
let mut cmd = Command::new("ssh-keygen");
cmd.arg("-t")
.arg("ed25519")
.arg("-f")
.arg(&key_path)
.arg("-N")
.arg("");
if !email.is_empty() {
cmd.arg("-C").arg(email);
}
println!("🔄 Génération de la clé SSH en cours...");
let output = cmd
.output()
.context("Impossible d'exécuter ssh-keygen. Assurez-vous qu'OpenSSH est installé.")?;
if output.status.success() {
println!(
"✅ Clé SSH Ed25519 générée avec succès: {}",
key_path.display()
);
println!("📋 Clé publique: {}.pub", key_path.display());
if let Ok(public_key) = fs::read_to_string(format!("{}.pub", key_path.display())) {
println!("\n📄 Contenu de votre clé publique:");
println!("{}", public_key.trim());
println!("\n💡 Copiez cette clé publique sur vos serveurs avec:");
println!(" ssh-copy-id -i ~/.ssh/id_ed25519.pub user@hostname");
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Échec de la génération de clé SSH: {}", stderr);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_parsing() {
let json_content = r#"
{
"Production": {
"Region-A": {
"Public": {
"WEB_01": {
"alias": "web01@prod.example.com",
"env": "PROD"
}
}
}
}
}
"#;
let config: HostsConfig = serde_json::from_str(json_content).unwrap();
assert_eq!(config.filter_hosts(None, None, None).len(), 1);
}
#[test]
fn test_host_filtering() {
let json_content = r#"
{
"Production": {
"Region-A": {
"Public": {
"WEB_01": {
"alias": "web01@prod.example.com",
"env": "PROD"
}
},
"Private": {
"DB_01": {
"alias": "db01@prod.example.com",
"env": "PROD"
}
}
}
},
"Staging": {
"Region-A": {
"Public": {
"WEB_01": {
"alias": "web01@stage.example.com",
"env": "STAGE"
}
}
}
}
}
"#;
let config: HostsConfig = serde_json::from_str(json_content).unwrap();
let prod_hosts = config.filter_hosts(Some(&"Production".to_string()), None, None);
assert_eq!(prod_hosts.len(), 2);
let public_hosts = config.filter_hosts(None, None, Some(&"Public".to_string()));
assert_eq!(public_hosts.len(), 2);
let prod_public = config.filter_hosts(
Some(&"Production".to_string()),
None,
Some(&"Public".to_string()),
);
assert_eq!(prod_public.len(), 1);
}
}