Skip to main content

ssh_cli/vps/
mod.rs

1//! CRUD e persistência de registros de VPS (XDG + TOML atômico + flock).
2//!
3//! ZERO arquivo `.env` em runtime. Schema v2 com auth senha/chave.
4
5pub mod modelo;
6
7use crate::cli::{AcaoSecrets, AcaoVps, FormatoSaida};
8use crate::erros::{ErroSshCli, ResultadoSshCli};
9use crate::output;
10use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait, ConfiguracaoConexao};
11use crate::ssh::known_hosts::KnownHosts;
12use crate::ssh::packing::{anexar_description, empacotar_su, empacotar_sudo};
13use anyhow::Result;
14use modelo::{limite_efetivo, parse_limite_chars, VpsRegistro};
15use secrecy::SecretString;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::io::{Read, Write};
19use std::path::{Path, PathBuf};
20
21/// Arquivo de configuração completo.
22#[derive(Debug, Default, Serialize, Deserialize)]
23pub struct ArquivoConfig {
24    /// Versão do schema do arquivo.
25    #[serde(default)]
26    pub schema_version: u32,
27    /// Mapa de VPSs por nome.
28    #[serde(default)]
29    pub hosts: BTreeMap<String, VpsRegistro>,
30}
31
32/// Resolve o caminho do arquivo de config a partir de um override opcional.
33pub fn resolver_caminho_config(override_path: Option<PathBuf>) -> ResultadoSshCli<PathBuf> {
34    match override_path {
35        Some(p) => {
36            if p.is_dir() {
37                return Ok(p.join("config.toml"));
38            }
39            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
40                return Ok(p);
41            }
42            Ok(p.join("config.toml"))
43        }
44        None => caminho_config_padrao(),
45    }
46}
47
48/// Retorna o caminho do arquivo de config respeitando `SSH_CLI_HOME`.
49pub fn caminho_config_padrao() -> ResultadoSshCli<PathBuf> {
50    if let Ok(home) = std::env::var("SSH_CLI_HOME") {
51        if home.contains("..") {
52            return Err(ErroSshCli::ArgumentoInvalido(
53                "SSH_CLI_HOME não pode conter '..'".to_string(),
54            ));
55        }
56        return Ok(PathBuf::from(home).join("config.toml"));
57    }
58
59    let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
60        ErroSshCli::Generico("não foi possível resolver diretório de config".to_string())
61    })?;
62    Ok(dirs.config_dir().join("config.toml"))
63}
64
65/// Camada vencedora de configuração (doctor).
66#[derive(Debug, Clone)]
67pub struct CamadaConfig {
68    /// Nome da camada.
69    pub nome: &'static str,
70    /// Path resolvido.
71    pub path: PathBuf,
72}
73
74/// Resolve e descreve a camada de config vencedora.
75pub fn camada_vencedora(override_path: Option<PathBuf>) -> ResultadoSshCli<CamadaConfig> {
76    if override_path.is_some() {
77        return Ok(CamadaConfig {
78            nome: "--config-dir",
79            path: resolver_caminho_config(override_path)?,
80        });
81    }
82    if std::env::var("SSH_CLI_HOME").is_ok() {
83        return Ok(CamadaConfig {
84            nome: "SSH_CLI_HOME",
85            path: caminho_config_padrao()?,
86        });
87    }
88    Ok(CamadaConfig {
89        nome: "XDG ProjectDirs",
90        path: caminho_config_padrao()?,
91    })
92}
93
94/// Carrega o arquivo de configuração (retorna vazio se não existir).
95pub fn carregar(caminho: &PathBuf) -> ResultadoSshCli<ArquivoConfig> {
96    if !caminho.exists() {
97        return Ok(ArquivoConfig {
98            schema_version: modelo::SCHEMA_VERSION_ATUAL,
99            hosts: BTreeMap::new(),
100        });
101    }
102    let conteudo = std::fs::read_to_string(caminho)?;
103    let mut arquivo: ArquivoConfig = toml::from_str(&conteudo)?;
104    for reg in arquivo.hosts.values_mut() {
105        reg.normalizar_schema();
106    }
107    if arquivo.schema_version < modelo::SCHEMA_VERSION_ATUAL {
108        arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
109    }
110    Ok(arquivo)
111}
112
113/// Escreve bytes em `caminho` de forma atômica (tempfile + fsync + rename + 0o600).
114///
115/// Usado por `salvar` e `export` (GAP-007 residual no export).
116pub fn escrever_atomico(caminho: &Path, bytes: &[u8]) -> ResultadoSshCli<()> {
117    if let Some(pai) = caminho.parent() {
118        std::fs::create_dir_all(pai)?;
119    }
120    let pai = caminho
121        .parent()
122        .map(Path::to_path_buf)
123        .unwrap_or_else(|| PathBuf::from("."));
124    let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
125    tmp.write_all(bytes)?;
126    tmp.as_file().sync_data()?;
127    tmp.persist(caminho).map_err(|e| ErroSshCli::Io(e.error))?;
128    aplicar_permissoes_600(caminho)?;
129    #[cfg(unix)]
130    {
131        if let Ok(dir) = std::fs::File::open(&pai) {
132            let _ = dir.sync_all();
133        }
134    }
135    Ok(())
136}
137
138/// Salva o arquivo de configuração de forma atômica com flock e 0o600.
139pub fn salvar(caminho: &Path, arquivo: &ArquivoConfig) -> ResultadoSshCli<()> {
140    if let Some(pai) = caminho.parent() {
141        std::fs::create_dir_all(pai)?;
142    }
143    let texto = toml::to_string_pretty(arquivo)
144        .map_err(|e| ErroSshCli::Generico(format!("falha serializando TOML: {e}")))?;
145
146    // Lock em arquivo irmão para serializar mutações concorrentes (N one-shots).
147    let lock_path = caminho.with_extension("toml.lock");
148    let lock_file = std::fs::OpenOptions::new()
149        .create(true)
150        .truncate(false)
151        .read(true)
152        .write(true)
153        .open(&lock_path)?;
154    fs2::FileExt::lock_exclusive(&lock_file)?;
155
156    escrever_atomico(caminho, texto.as_bytes())?;
157
158    let _ = fs2::FileExt::unlock(&lock_file);
159    Ok(())
160}
161
162#[cfg(unix)]
163fn aplicar_permissoes_600(caminho: &Path) -> ResultadoSshCli<()> {
164    use std::os::unix::fs::PermissionsExt;
165    let mut permissoes = std::fs::metadata(caminho)?.permissions();
166    permissoes.set_mode(0o600);
167    std::fs::set_permissions(caminho, permissoes)?;
168    Ok(())
169}
170
171#[cfg(not(unix))]
172fn aplicar_permissoes_600(_caminho: &Path) -> ResultadoSshCli<()> {
173    Ok(())
174}
175
176/// Lê uma linha de senha de stdin (sem eco extra).
177pub fn ler_segredo_stdin() -> ResultadoSshCli<String> {
178    let mut buf = String::new();
179    std::io::stdin().read_to_string(&mut buf)?;
180    Ok(buf.trim_end_matches(['\r', '\n']).to_string())
181}
182
183/// Aplica overrides de runtime sobre um VpsRegistro clonado.
184fn aplicar_overrides(
185    vps: &mut VpsRegistro,
186    password_override: Option<String>,
187    sudo_password_override: Option<String>,
188    su_password_override: Option<String>,
189    timeout_override: Option<u64>,
190    key_path_override: Option<String>,
191    key_passphrase_override: Option<String>,
192) {
193    if let Some(pwd) = password_override {
194        vps.senha = SecretString::from(pwd);
195    }
196    if let Some(spwd) = sudo_password_override {
197        vps.senha_sudo = Some(SecretString::from(spwd));
198    }
199    if let Some(sp) = su_password_override {
200        vps.senha_su = Some(SecretString::from(sp));
201    }
202    if let Some(t) = timeout_override {
203        vps.timeout_ms = t;
204    }
205    if let Some(k) = key_path_override {
206        vps.key_path = Some(k);
207    }
208    if let Some(kp) = key_passphrase_override {
209        vps.key_passphrase = Some(SecretString::from(kp));
210    }
211}
212
213fn validar_comando_tamanho(comando: &str, max_command_chars: usize) -> ResultadoSshCli<()> {
214    let lim = limite_efetivo(max_command_chars);
215    let len = comando.chars().count();
216    if len > lim {
217        return Err(ErroSshCli::ComandoMuitoLongo {
218            max: max_command_chars,
219            len,
220        });
221    }
222    if comando.trim().is_empty() {
223        return Err(ErroSshCli::ArgumentoInvalido("comando vazio".to_string()));
224    }
225    Ok(())
226}
227
228/// Dispatcher dos subcomandos `vps`.
229pub async fn executar_comando_vps(
230    acao: AcaoVps,
231    config_override: Option<PathBuf>,
232    _formato: FormatoSaida,
233) -> Result<()> {
234    let caminho = resolver_caminho_config(config_override.clone())?;
235
236    match acao {
237        AcaoVps::Add {
238            name,
239            host,
240            port,
241            user,
242            password,
243            password_stdin,
244            key,
245            key_passphrase,
246            timeout,
247            max_command_chars,
248            max_output_chars,
249            max_chars,
250            sudo_password,
251            sudo_password_stdin,
252            su_password,
253            su_password_stdin,
254            disable_sudo,
255            check,
256        } => {
257            let name = crate::paths::normalizar_nfc(&name);
258            let mut arquivo = carregar(&caminho)?;
259            if arquivo.hosts.contains_key(&name) {
260                return Err(ErroSshCli::VpsDuplicada(name).into());
261            }
262            if password_stdin && (sudo_password_stdin || su_password_stdin) {
263                return Err(ErroSshCli::ArgumentoInvalido(
264                    "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
265                )
266                .into());
267            }
268            let senha = if password_stdin {
269                SecretString::from(ler_segredo_stdin()?)
270            } else {
271                SecretString::from(password.unwrap_or_default())
272            };
273            let sudo_s = if sudo_password_stdin {
274                Some(SecretString::from(ler_segredo_stdin()?))
275            } else {
276                sudo_password.map(SecretString::from)
277            };
278            let su_s = if su_password_stdin {
279                Some(SecretString::from(ler_segredo_stdin()?))
280            } else {
281                su_password.map(SecretString::from)
282            };
283            // max_chars legado → command se max_command não veio explicitamente
284            let max_cmd = max_command_chars
285                .as_deref()
286                .or(max_chars.as_deref())
287                .map(parse_limite_chars)
288                .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
289            let max_out = max_output_chars
290                .as_deref()
291                .map(parse_limite_chars)
292                .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
293            let registro = VpsRegistro::novo(
294                name.clone(),
295                host,
296                port,
297                user,
298                senha,
299                key,
300                key_passphrase.map(SecretString::from),
301                Some(timeout),
302                Some(max_cmd),
303                Some(max_out),
304                sudo_s,
305                su_s,
306                disable_sudo,
307            );
308            registro
309                .validar_credenciais()
310                .map_err(ErroSshCli::ArgumentoInvalido)?;
311            arquivo.hosts.insert(name.clone(), registro);
312            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
313            salvar(&caminho, &arquivo)?;
314            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
315            if check {
316                executar_health_check(Some(&name), config_override, FormatoSaida::Text, None)
317                    .await?;
318            }
319        }
320        AcaoVps::List { json } => {
321            let arquivo = carregar(&caminho)?;
322            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
323            if json {
324                crate::output::imprimir_lista_json(&registros);
325            } else {
326                crate::output::imprimir_lista_texto(&registros);
327            }
328        }
329        AcaoVps::Remove { nome } => {
330            let mut arquivo = carregar(&caminho)?;
331            if arquivo.hosts.remove(&nome).is_none() {
332                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
333            }
334            salvar(&caminho, &arquivo)?;
335            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
336        }
337        AcaoVps::Edit {
338            nome,
339            host,
340            port,
341            user,
342            password,
343            password_stdin,
344            key,
345            key_passphrase,
346            timeout,
347            max_command_chars,
348            max_output_chars,
349            max_chars,
350            sudo_password,
351            sudo_password_stdin,
352            su_password,
353            su_password_stdin,
354            disable_sudo,
355        } => {
356            let mut arquivo = carregar(&caminho)?;
357            let registro = arquivo
358                .hosts
359                .get_mut(&nome)
360                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
361            if let Some(h) = host {
362                registro.host = h;
363            }
364            if let Some(p) = port {
365                registro.porta = p;
366            }
367            if let Some(u) = user {
368                registro.usuario = u;
369            }
370            if password_stdin {
371                registro.senha = SecretString::from(ler_segredo_stdin()?);
372            } else if let Some(pw) = password {
373                registro.senha = SecretString::from(pw);
374            }
375            if let Some(k) = key {
376                registro.key_path = Some(k);
377            }
378            if let Some(kp) = key_passphrase {
379                registro.key_passphrase = Some(SecretString::from(kp));
380            }
381            if let Some(t) = timeout {
382                registro.timeout_ms = t;
383            }
384            if let Some(m) = max_command_chars.or(max_chars) {
385                registro.max_command_chars = parse_limite_chars(&m);
386            }
387            if let Some(m) = max_output_chars {
388                registro.max_output_chars = parse_limite_chars(&m);
389            }
390            if sudo_password_stdin {
391                registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
392            } else if let Some(sp) = sudo_password {
393                registro.senha_sudo = Some(SecretString::from(sp));
394            }
395            if su_password_stdin {
396                registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
397            } else if let Some(sp) = su_password {
398                registro.senha_su = Some(SecretString::from(sp));
399            }
400            if let Some(d) = disable_sudo {
401                registro.disable_sudo = d;
402            }
403            registro
404                .validar_credenciais()
405                .map_err(ErroSshCli::ArgumentoInvalido)?;
406            salvar(&caminho, &arquivo)?;
407            crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
408        }
409        AcaoVps::Show { nome, json } => {
410            let arquivo = carregar(&caminho)?;
411            let registro = arquivo
412                .hosts
413                .get(&nome)
414                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
415            if json {
416                crate::output::imprimir_detalhes_json(registro);
417            } else {
418                crate::output::imprimir_detalhes_texto(registro);
419            }
420        }
421        AcaoVps::Path => {
422            crate::output::escrever_linha(&caminho.display().to_string())?;
423        }
424        AcaoVps::Doctor { json } => {
425            executar_doctor(config_override, json)?;
426        }
427        AcaoVps::Export {
428            include_secrets,
429            output,
430        } => {
431            executar_export(&caminho, include_secrets, output.as_deref())?;
432        }
433        AcaoVps::Import { file } => {
434            executar_import(&caminho, &file)?;
435        }
436    }
437    Ok(())
438}
439
440fn executar_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
441    let camada = camada_vencedora(config_override.clone())?;
442    let caminho = camada.path.clone();
443    let existe = caminho.exists();
444    let arquivo = carregar(&caminho)?;
445    let kh = KnownHosts::caminho_ao_lado_config(&caminho);
446    let active = caminho
447        .parent()
448        .map(|p| p.join("active"))
449        .unwrap_or_else(|| PathBuf::from("active"));
450    let perms = if existe {
451        #[cfg(unix)]
452        {
453            use std::os::unix::fs::PermissionsExt;
454            format!(
455                "{:o}",
456                std::fs::metadata(&caminho)?.permissions().mode() & 0o777
457            )
458        }
459        #[cfg(not(unix))]
460        {
461            "n/a".to_string()
462        }
463    } else {
464        "ausente".to_string()
465    };
466
467    let seg = crate::secrets::status_segredos()?;
468    if json {
469        let v = serde_json::json!({
470            "layer": camada.nome,
471            "config_path": caminho.display().to_string(),
472            "exists": existe,
473            "permissions": perms,
474            "schema_version": arquivo.schema_version,
475            "hosts": arquivo.hosts.len(),
476            "known_hosts": kh.display().to_string(),
477            "active_file": active.display().to_string(),
478            "secrets_at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
479            "secrets_key_source": seg.fonte.as_str(),
480            "secrets_key_file": seg.key_file_path.display().to_string(),
481            "secrets_plaintext_opt_out": seg.plaintext_opt_out,
482            "telemetry": false,
483        });
484        println!("{}", serde_json::to_string_pretty(&v)?);
485    } else {
486        println!("Camada vencedora: {}", camada.nome);
487        println!("Config path:      {}", caminho.display());
488        println!("Existe:           {existe}");
489        println!("Permissões:       {perms}");
490        println!("Schema:           {}", arquivo.schema_version);
491        println!("Hosts:            {}", arquivo.hosts.len());
492        println!("known_hosts:      {}", kh.display());
493        println!("active file:      {}", active.display());
494        println!(
495            "Secrets at-rest:  {} (key source: {})",
496            if seg.cifragem_ativa {
497                "encrypted"
498            } else {
499                "plaintext"
500            },
501            seg.fonte.as_str()
502        );
503        println!("Secrets key file: {}", seg.key_file_path.display());
504        println!(
505            "Plaintext opt-out: {}",
506            if seg.plaintext_opt_out { "yes" } else { "no" }
507        );
508        println!("Telemetria:       desabilitada");
509    }
510    Ok(())
511}
512
513fn executar_export(caminho: &PathBuf, include_secrets: bool, output: Option<&str>) -> Result<()> {
514    let arquivo = carregar(caminho)?;
515    let mut export = ArquivoConfig {
516        schema_version: arquivo.schema_version,
517        hosts: BTreeMap::new(),
518    };
519    for (k, mut v) in arquivo.hosts {
520        if !include_secrets {
521            v.senha = SecretString::from(String::new());
522            v.senha_sudo = None;
523            v.senha_su = None;
524            v.key_passphrase = None;
525        }
526        export.hosts.insert(k, v);
527    }
528    let texto = toml::to_string_pretty(&export)?;
529    if let Some(path) = output {
530        escrever_atomico(Path::new(path), texto.as_bytes())?;
531        crate::output::imprimir_sucesso(&format!("exportado para {path}"));
532    } else {
533        print!("{texto}");
534    }
535    Ok(())
536}
537
538fn executar_import(caminho: &PathBuf, file: &Path) -> Result<()> {
539    let texto = std::fs::read_to_string(file)?;
540    let importado: ArquivoConfig = toml::from_str(&texto)?;
541    let mut atual = carregar(caminho)?;
542    for (k, v) in importado.hosts {
543        v.validar_credenciais()
544            .map_err(ErroSshCli::ArgumentoInvalido)?;
545        atual.hosts.insert(k, v);
546    }
547    atual.schema_version = modelo::SCHEMA_VERSION_ATUAL;
548    salvar(caminho, &atual)?;
549    crate::output::imprimir_sucesso("importação concluída");
550    Ok(())
551}
552
553/// Dispatcher one-shot de `secrets status|init|reencrypt`.
554pub async fn executar_comando_secrets(
555    acao: AcaoSecrets,
556    config_override: Option<PathBuf>,
557    formato: FormatoSaida,
558) -> Result<()> {
559    // Garante alinhamento do secrets.key com --config-dir.
560    crate::secrets::definir_diretorio_config(config_override.clone());
561    match acao {
562        AcaoSecrets::Status { json } => {
563            let seg = crate::secrets::status_segredos()?;
564            let usar_json = json || formato == FormatoSaida::Json;
565            if usar_json {
566                let v = serde_json::json!({
567                    "encryption_active": seg.cifragem_ativa,
568                    "key_source": seg.fonte.as_str(),
569                    "key_file": seg.key_file_path.display().to_string(),
570                    "plaintext_opt_out": seg.plaintext_opt_out,
571                    "at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
572                });
573                println!("{}", serde_json::to_string_pretty(&v)?);
574            } else {
575                println!(
576                    "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
577                    if seg.cifragem_ativa {
578                        "encrypted"
579                    } else {
580                        "plaintext"
581                    },
582                    seg.fonte.as_str(),
583                    seg.key_file_path.display(),
584                    seg.plaintext_opt_out
585                );
586            }
587            Ok(())
588        }
589        AcaoSecrets::Init { keyring, force } => {
590            let seg = crate::secrets::init_master_key(keyring, force)?;
591            crate::output::imprimir_sucesso(&format!(
592                "master-key pronta (source={}; key_file={})",
593                seg.fonte.as_str(),
594                seg.key_file_path.display()
595            ));
596            Ok(())
597        }
598        AcaoSecrets::Reencrypt => {
599            let caminho = resolver_caminho_config(config_override)?;
600            executar_reencrypt(&caminho)?;
601            Ok(())
602        }
603    }
604}
605
606/// Recarrega e regrava o config, re-cifando secrets com a chave atual.
607fn executar_reencrypt(caminho: &PathBuf) -> Result<()> {
608    let (chave, fonte) = crate::secrets::garantir_chave_para_escrita()?;
609    if chave.is_none() {
610        return Err(ErroSshCli::ArgumentoInvalido(
611            "sem master-key; rode `ssh-cli secrets init` ou remova SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
612                .to_string(),
613        )
614        .into());
615    }
616    if let Some(mut k) = chave {
617        use zeroize::Zeroize;
618        k.zeroize();
619    }
620    let arquivo = carregar(caminho)?;
621    salvar(caminho, &arquivo)?;
622    crate::output::imprimir_sucesso(&format!(
623        "reencrypt ok (source={}; hosts={})",
624        fonte.as_str(),
625        arquivo.hosts.len()
626    ));
627    Ok(())
628}
629
630/// Define a VPS ativa gravando seu nome em `<config_dir>/active` (arquivo irmão).
631pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
632    let caminho = resolver_caminho_config(config_override)?;
633    let arquivo = carregar(&caminho)?;
634    if !arquivo.hosts.contains_key(nome) {
635        return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
636    }
637
638    let arquivo_ativo = caminho
639        .parent()
640        .map(|p| p.join("active"))
641        .unwrap_or_else(|| PathBuf::from("active"));
642    if let Some(pai) = arquivo_ativo.parent() {
643        std::fs::create_dir_all(pai)?;
644    }
645    // escrita atômica do active
646    let pai = arquivo_ativo
647        .parent()
648        .map(Path::to_path_buf)
649        .unwrap_or_else(|| PathBuf::from("."));
650    let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
651    tmp.write_all(nome.as_bytes())?;
652    tmp.as_file().sync_data()?;
653    tmp.persist(&arquivo_ativo)
654        .map_err(|e| ErroSshCli::Io(e.error))?;
655    crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
656    Ok(())
657}
658
659/// Busca um registro de VPS por nome.
660pub fn buscar_por_nome(
661    config_override: Option<PathBuf>,
662    nome: &str,
663) -> ResultadoSshCli<Option<VpsRegistro>> {
664    let caminho = resolver_caminho_config(config_override)?;
665    let arquivo = carregar(&caminho)?;
666    Ok(arquivo.hosts.get(nome).cloned())
667}
668
669/// Lê o nome da VPS ativa.
670pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
671    let caminho = resolver_caminho_config(config_override)?;
672    let arquivo_ativo = caminho
673        .parent()
674        .map(|p| p.join("active"))
675        .unwrap_or_else(|| PathBuf::from("active"));
676    if !arquivo_ativo.exists() {
677        return Ok(None);
678    }
679    let nome = std::fs::read_to_string(&arquivo_ativo)?;
680    Ok(Some(nome.trim().to_string()))
681}
682
683/// Constrói `ConfiguracaoConexao` a partir de um `VpsRegistro`.
684pub fn construir_configuracao(
685    vps: &VpsRegistro,
686    config_toml: Option<&Path>,
687    replace_host_key: bool,
688) -> ConfiguracaoConexao {
689    let known_hosts_path = config_toml.map(KnownHosts::caminho_ao_lado_config);
690    ConfiguracaoConexao {
691        host: vps.host.clone(),
692        porta: vps.porta,
693        usuario: vps.usuario.clone(),
694        senha: vps.senha.clone(),
695        key_path: vps.key_path.clone(),
696        key_passphrase: vps.key_passphrase.clone(),
697        timeout_ms: vps.timeout_ms,
698        known_hosts_path,
699        replace_host_key,
700    }
701}
702
703/// Opções comuns de execução remota.
704#[derive(Debug, Default, Clone)]
705pub struct OpcoesExec {
706    /// Override senha.
707    pub password: Option<String>,
708    /// Override sudo.
709    pub sudo_password: Option<String>,
710    /// Override su.
711    pub su_password: Option<String>,
712    /// Override timeout.
713    pub timeout: Option<u64>,
714    /// Override key path.
715    pub key: Option<String>,
716    /// Override key passphrase.
717    pub key_passphrase: Option<String>,
718    /// Optional shell description comment.
719    pub description: Option<String>,
720    /// replace host key.
721    pub replace_host_key: bool,
722    /// disable sudo global.
723    pub disable_sudo: bool,
724}
725
726/// Executa um comando em uma VPS via SSH.
727pub async fn executar_exec(
728    vps_nome: &str,
729    comando: &str,
730    config_override: Option<PathBuf>,
731    formato: FormatoSaida,
732    json: bool,
733    opts: OpcoesExec,
734) -> Result<()> {
735    if crate::signals::cancelado() || crate::signals::terminado() {
736        return Err(anyhow::anyhow!(crate::i18n::t(
737            crate::i18n::Mensagem::OperacaoCancelada
738        )));
739    }
740    let caminho = resolver_caminho_config(config_override)?;
741    let arquivo = carregar(&caminho)?;
742    let vps_base = arquivo
743        .hosts
744        .get(vps_nome)
745        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
746
747    let mut vps = vps_base.clone();
748    aplicar_overrides(
749        &mut vps,
750        opts.password,
751        opts.sudo_password,
752        opts.su_password,
753        opts.timeout,
754        opts.key,
755        opts.key_passphrase,
756    );
757    let cmd = anexar_description(comando, opts.description.as_deref());
758    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
759    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
760    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
761    executar_exec_with_client(&vps, &cmd, cliente, formato, json).await
762}
763
764/// Versão testável de executar_exec.
765pub async fn executar_exec_with_client(
766    vps: &VpsRegistro,
767    comando: &str,
768    mut cliente: Box<dyn ClienteSshTrait>,
769    formato: FormatoSaida,
770    json: bool,
771) -> Result<()> {
772    if crate::signals::cancelado() || crate::signals::terminado() {
773        return Err(anyhow::anyhow!(crate::i18n::t(
774            crate::i18n::Mensagem::OperacaoCancelada
775        )));
776    }
777    let max_out = limite_efetivo(vps.max_output_chars);
778    let saida = cliente.executar_comando(comando, max_out).await?;
779    cliente.desconectar().await?;
780    if formato == FormatoSaida::Json || json {
781        output::imprimir_saida_execucao_json(&saida);
782    } else {
783        output::imprimir_saida_execucao(&saida);
784    }
785    if let Some(code) = saida.exit_code {
786        if code != 0 {
787            return Err(ErroSshCli::ComandoFalhou {
788                exit_code: code,
789                stderr: saida.stderr.clone(),
790            }
791            .into());
792        }
793    }
794    Ok(())
795}
796
797/// Executa um comando com `sudo` (packing `sh -c`).
798pub async fn executar_sudo_exec(
799    vps_nome: &str,
800    comando: &str,
801    config_override: Option<PathBuf>,
802    formato: FormatoSaida,
803    json: bool,
804    opts: OpcoesExec,
805) -> Result<()> {
806    if crate::signals::cancelado() || crate::signals::terminado() {
807        return Err(anyhow::anyhow!(crate::i18n::t(
808            crate::i18n::Mensagem::OperacaoCancelada
809        )));
810    }
811    let caminho = resolver_caminho_config(config_override)?;
812    let arquivo = carregar(&caminho)?;
813    let vps_base = arquivo
814        .hosts
815        .get(vps_nome)
816        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
817
818    let mut vps = vps_base.clone();
819    aplicar_overrides(
820        &mut vps,
821        opts.password.clone(),
822        opts.sudo_password.clone(),
823        opts.su_password.clone(),
824        opts.timeout,
825        opts.key.clone(),
826        opts.key_passphrase.clone(),
827    );
828    if opts.disable_sudo || vps.disable_sudo {
829        return Err(ErroSshCli::SudoDesabilitado.into());
830    }
831    let cmd = anexar_description(comando, opts.description.as_deref());
832    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
833    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
834    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
835    executar_sudo_exec_with_client(&vps, &cmd, cliente, formato, json).await
836}
837
838/// Versão testável de sudo-exec.
839pub async fn executar_sudo_exec_with_client(
840    vps: &VpsRegistro,
841    comando: &str,
842    mut cliente: Box<dyn ClienteSshTrait>,
843    formato: FormatoSaida,
844    json: bool,
845) -> Result<()> {
846    if crate::signals::cancelado() || crate::signals::terminado() {
847        return Err(anyhow::anyhow!(crate::i18n::t(
848            crate::i18n::Mensagem::OperacaoCancelada
849        )));
850    }
851    if vps.disable_sudo {
852        return Err(ErroSshCli::SudoDesabilitado.into());
853    }
854    let sudo_cmd = empacotar_sudo(comando, vps.senha_sudo.as_ref());
855    let max_out = limite_efetivo(vps.max_output_chars);
856    let saida = cliente.executar_comando(&sudo_cmd, max_out).await?;
857    cliente.desconectar().await?;
858    if formato == FormatoSaida::Json || json {
859        output::imprimir_saida_execucao_json(&saida);
860    } else {
861        output::imprimir_saida_execucao(&saida);
862    }
863    if let Some(code) = saida.exit_code {
864        if code != 0 {
865            return Err(ErroSshCli::ComandoFalhou {
866                exit_code: code,
867                stderr: saida.stderr.clone(),
868            }
869            .into());
870        }
871    }
872    Ok(())
873}
874
875/// Executa comando via `su -` one-shot (consome `senha_su`).
876pub async fn executar_su_exec(
877    vps_nome: &str,
878    comando: &str,
879    config_override: Option<PathBuf>,
880    formato: FormatoSaida,
881    json: bool,
882    opts: OpcoesExec,
883) -> Result<()> {
884    if crate::signals::cancelado() || crate::signals::terminado() {
885        return Err(anyhow::anyhow!(crate::i18n::t(
886            crate::i18n::Mensagem::OperacaoCancelada
887        )));
888    }
889    let caminho = resolver_caminho_config(config_override)?;
890    let arquivo = carregar(&caminho)?;
891    let vps_base = arquivo
892        .hosts
893        .get(vps_nome)
894        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
895
896    let mut vps = vps_base.clone();
897    aplicar_overrides(
898        &mut vps,
899        opts.password,
900        opts.sudo_password,
901        opts.su_password,
902        opts.timeout,
903        opts.key,
904        opts.key_passphrase,
905    );
906    if opts.disable_sudo || vps.disable_sudo {
907        return Err(ErroSshCli::SudoDesabilitado.into());
908    }
909    let senha_su = vps.senha_su.clone().ok_or(ErroSshCli::SenhaSuAusente)?;
910    let cmd = anexar_description(comando, opts.description.as_deref());
911    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
912    let su_cmd = empacotar_su(&cmd, &senha_su);
913    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
914    let mut cliente: Box<dyn ClienteSshTrait> =
915        <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
916    let max_out = limite_efetivo(vps.max_output_chars);
917    let saida = cliente.executar_comando(&su_cmd, max_out).await?;
918    cliente.desconectar().await?;
919    if formato == FormatoSaida::Json || json {
920        output::imprimir_saida_execucao_json(&saida);
921    } else {
922        output::imprimir_saida_execucao(&saida);
923    }
924    if let Some(code) = saida.exit_code {
925        if code != 0 {
926            return Err(ErroSshCli::ComandoFalhou {
927                exit_code: code,
928                stderr: saida.stderr.clone(),
929            }
930            .into());
931        }
932    }
933    Ok(())
934}
935
936/// Health-check SSH.
937pub async fn executar_health_check(
938    vps_nome: Option<&str>,
939    config_override: Option<PathBuf>,
940    formato: FormatoSaida,
941    password_override: Option<String>,
942) -> Result<()> {
943    if crate::signals::cancelado() || crate::signals::terminado() {
944        return Err(anyhow::anyhow!(crate::i18n::t(
945            crate::i18n::Mensagem::OperacaoCancelada
946        )));
947    }
948    let nome_resolvido: String = match vps_nome {
949        Some(n) => n.to_string(),
950        None => {
951            let ativa = ler_vps_ativa(config_override.clone())?;
952            ativa.ok_or_else(|| {
953                anyhow::anyhow!(crate::i18n::t(crate::i18n::Mensagem::HealthCheckSemVps))
954            })?
955        }
956    };
957    let caminho = resolver_caminho_config(config_override)?;
958    let arquivo = carregar(&caminho)?;
959    let vps_base = arquivo
960        .hosts
961        .get(&nome_resolvido)
962        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
963
964    let mut vps = vps_base.clone();
965    aplicar_overrides(&mut vps, password_override, None, None, None, None, None);
966    let cfg = construir_configuracao(&vps, Some(&caminho), false);
967    let inicio = std::time::Instant::now();
968    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
969    let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
970    cliente.desconectar().await?;
971
972    if formato == FormatoSaida::Json {
973        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
974    } else {
975        output::imprimir_health_check(&nome_resolvido, latencia_ms);
976    }
977    Ok(())
978}
979
980#[cfg(test)]
981mod testes {
982    use super::*;
983    use secrecy::ExposeSecret;
984    use tempfile::TempDir;
985
986    fn reg_min() -> VpsRegistro {
987        VpsRegistro::novo(
988            "srv".into(),
989            "host.example.com".into(),
990            2222,
991            "admin".into(),
992            SecretString::from("pass".to_string()),
993            None,
994            None,
995            Some(60_000),
996            Some(1_000),
997            Some(50_000),
998            None,
999            None,
1000            false,
1001        )
1002    }
1003
1004    #[test]
1005    fn arquivo_vazio_serializa_com_schema() {
1006        let arq = ArquivoConfig {
1007            schema_version: modelo::SCHEMA_VERSION_ATUAL,
1008            hosts: BTreeMap::new(),
1009        };
1010        let texto = toml::to_string(&arq).unwrap();
1011        assert!(texto.contains("schema_version = 2"));
1012    }
1013
1014    #[test]
1015    fn parse_limite_none() {
1016        assert_eq!(parse_limite_chars("none"), 0);
1017        assert_eq!(parse_limite_chars("0"), 0);
1018        assert_eq!(parse_limite_chars("1000"), 1000);
1019    }
1020
1021    #[test]
1022    fn construir_configuracao_copia_campos() {
1023        let registro = reg_min();
1024        let cfg = construir_configuracao(&registro, None, false);
1025        assert_eq!(cfg.host, "host.example.com");
1026        assert_eq!(cfg.porta, 2222);
1027        assert_eq!(cfg.usuario, "admin");
1028        assert_eq!(cfg.timeout_ms, 60_000);
1029        assert!(cfg.known_hosts_path.is_none());
1030    }
1031
1032    #[test]
1033    fn salvar_atomico_roundtrip() {
1034        let tmp = TempDir::new().unwrap();
1035        let path = tmp.path().join("config.toml");
1036        let mut arq = ArquivoConfig {
1037            schema_version: 2,
1038            hosts: BTreeMap::new(),
1039        };
1040        arq.hosts.insert("a".into(), reg_min());
1041        salvar(&path, &arq).unwrap();
1042        let lido = carregar(&path).unwrap();
1043        assert_eq!(lido.hosts.len(), 1);
1044        assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1045        #[cfg(unix)]
1046        {
1047            use std::os::unix::fs::PermissionsExt;
1048            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1049            assert_eq!(mode, 0o600);
1050        }
1051    }
1052
1053    #[test]
1054    fn resolver_caminho_config_com_override_diretorio() {
1055        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1056        assert_eq!(
1057            resultado.unwrap(),
1058            PathBuf::from("/tmp/test-dir/config.toml")
1059        );
1060    }
1061
1062    #[test]
1063    fn validar_comando_longo() {
1064        let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1065        assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1066    }
1067
1068    #[test]
1069    fn empacotar_sudo_integrado() {
1070        let cmd = empacotar_sudo("ls -la", None);
1071        assert_eq!(cmd, "sudo -n sh -c 'ls -la'");
1072    }
1073
1074    #[test]
1075    fn aplicar_overrides_senha() {
1076        let mut v = reg_min();
1077        aplicar_overrides(
1078            &mut v,
1079            Some("nova".into()),
1080            Some("sudo".into()),
1081            None,
1082            Some(1000),
1083            Some("/k".into()),
1084            None,
1085        );
1086        assert_eq!(v.senha.expose_secret(), "nova");
1087        assert_eq!(v.timeout_ms, 1000);
1088        assert_eq!(v.key_path.as_deref(), Some("/k"));
1089    }
1090
1091    #[tokio::test]
1092    async fn executar_sudo_exec_with_client_ok() {
1093        use crate::ssh::cliente::mocks::MockClienteSsh;
1094        use crate::ssh::cliente::SaidaExecucao;
1095        use mockall::predicate::*;
1096
1097        let mut mock = MockClienteSsh::new();
1098        mock.expect_executar_comando()
1099            .with(function(|c: &str| c.contains("sudo -n sh -c")), always())
1100            .returning(|_, _| {
1101                Ok(SaidaExecucao {
1102                    stdout: "ok".into(),
1103                    stderr: String::new(),
1104                    exit_code: Some(0),
1105                    truncado_stdout: false,
1106                    truncado_stderr: false,
1107                    duracao_ms: 1,
1108                })
1109            });
1110        mock.expect_desconectar().returning(|| Ok(()));
1111
1112        let vps = reg_min();
1113        executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1114            .await
1115            .unwrap();
1116    }
1117}