1pub 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#[derive(Debug, Default, Serialize, Deserialize)]
23pub struct ArquivoConfig {
24 #[serde(default)]
26 pub schema_version: u32,
27 #[serde(default)]
29 pub hosts: BTreeMap<String, VpsRegistro>,
30}
31
32pub 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
48pub 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#[derive(Debug, Clone)]
67pub struct CamadaConfig {
68 pub nome: &'static str,
70 pub path: PathBuf,
72}
73
74pub 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
94pub 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
113pub 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
138pub 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 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
176pub 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
183fn 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
228pub 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 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(®istros);
325 } else {
326 crate::output::imprimir_lista_texto(®istros);
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
553pub async fn executar_comando_secrets(
555 acao: AcaoSecrets,
556 config_override: Option<PathBuf>,
557 formato: FormatoSaida,
558) -> Result<()> {
559 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
606fn 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
630pub 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 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
659pub 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
669pub 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
683pub 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#[derive(Debug, Default, Clone)]
705pub struct OpcoesExec {
706 pub password: Option<String>,
708 pub sudo_password: Option<String>,
710 pub su_password: Option<String>,
712 pub timeout: Option<u64>,
714 pub key: Option<String>,
716 pub key_passphrase: Option<String>,
718 pub description: Option<String>,
720 pub replace_host_key: bool,
722 pub disable_sudo: bool,
724}
725
726pub 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
764pub 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
797pub 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
838pub 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
875pub 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
936pub 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(®istro, 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}