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 aplicar_permissoes_600(&lock_path)?;
156 fs2::FileExt::lock_exclusive(&lock_file)?;
157
158 escrever_atomico(caminho, texto.as_bytes())?;
159
160 let _ = fs2::FileExt::unlock(&lock_file);
161 Ok(())
162}
163
164fn expandir_tilde(path: &str) -> PathBuf {
166 let home = std::env::var_os("HOME")
167 .or_else(|| std::env::var_os("USERPROFILE"))
168 .map(PathBuf::from);
169 if let Some(rest) = path.strip_prefix("~/") {
170 if let Some(home) = home {
171 return home.join(rest);
172 }
173 }
174 if path == "~" {
175 if let Some(home) = home {
176 return home;
177 }
178 }
179 PathBuf::from(path)
180}
181
182fn validar_key_path_existe(key_path: &str) -> Result<(), ErroSshCli> {
188 validar_key_path_existe_com_passphrase(key_path, None)
189}
190
191fn validar_key_path_existe_com_passphrase(
193 key_path: &str,
194 passphrase: Option<&str>,
195) -> Result<(), ErroSshCli> {
196 let p = expandir_tilde(key_path);
197 if !p.is_file() {
198 return Err(ErroSshCli::ArquivoNaoEncontrado(format!(
199 "chave privada não encontrada: {}",
200 p.display()
201 )));
202 }
203 #[cfg(feature = "ssh-real")]
204 {
205 match russh::keys::load_secret_key(&p, passphrase) {
206 Ok(_) => Ok(()),
207 Err(e) => {
208 let msg = e.to_string().to_lowercase();
209 if msg.contains("password")
211 || msg.contains("passphrase")
212 || msg.contains("encrypted")
213 || msg.contains("decrypt")
214 {
215 return Ok(());
216 }
217 Err(ErroSshCli::ArgumentoInvalido(format!(
218 "chave privada OpenSSH inválida em {}: {e}",
219 p.display()
220 )))
221 }
222 }
223 }
224 #[cfg(not(feature = "ssh-real"))]
225 {
226 let _ = passphrase;
227 Ok(())
228 }
229}
230
231#[must_use]
233pub fn usar_json(json_local: bool, formato: FormatoSaida) -> bool {
234 json_local || formato == FormatoSaida::Json
235}
236
237#[cfg(unix)]
238fn aplicar_permissoes_600(caminho: &Path) -> ResultadoSshCli<()> {
239 use std::os::unix::fs::PermissionsExt;
240 let mut permissoes = std::fs::metadata(caminho)?.permissions();
241 permissoes.set_mode(0o600);
242 std::fs::set_permissions(caminho, permissoes)?;
243 Ok(())
244}
245
246#[cfg(not(unix))]
247fn aplicar_permissoes_600(_caminho: &Path) -> ResultadoSshCli<()> {
248 Ok(())
249}
250
251pub fn ler_segredo_stdin() -> ResultadoSshCli<String> {
253 let mut buf = String::new();
254 std::io::stdin().read_to_string(&mut buf)?;
255 Ok(buf.trim_end_matches(['\r', '\n']).to_string())
256}
257
258fn aplicar_overrides(
260 vps: &mut VpsRegistro,
261 password_override: Option<String>,
262 sudo_password_override: Option<String>,
263 su_password_override: Option<String>,
264 timeout_override: Option<u64>,
265 key_path_override: Option<String>,
266 key_passphrase_override: Option<String>,
267) {
268 if let Some(pwd) = password_override {
269 vps.senha = SecretString::from(pwd);
270 }
271 if let Some(spwd) = sudo_password_override {
272 vps.senha_sudo = Some(SecretString::from(spwd));
273 }
274 if let Some(sp) = su_password_override {
275 vps.senha_su = Some(SecretString::from(sp));
276 }
277 if let Some(t) = timeout_override {
278 vps.timeout_ms = t;
279 }
280 if let Some(k) = key_path_override {
281 vps.key_path = Some(k);
282 }
283 if let Some(kp) = key_passphrase_override {
284 vps.key_passphrase = Some(SecretString::from(kp));
285 }
286}
287
288fn validar_comando_tamanho(comando: &str, max_command_chars: usize) -> ResultadoSshCli<()> {
289 let lim = limite_efetivo(max_command_chars);
290 let len = comando.chars().count();
291 if len > lim {
292 return Err(ErroSshCli::ComandoMuitoLongo {
293 max: max_command_chars,
294 len,
295 });
296 }
297 if comando.trim().is_empty() {
298 return Err(ErroSshCli::ArgumentoInvalido("comando vazio".to_string()));
299 }
300 Ok(())
301}
302
303pub async fn executar_comando_vps(
305 acao: AcaoVps,
306 config_override: Option<PathBuf>,
307 formato: FormatoSaida,
308) -> Result<()> {
309 let caminho = resolver_caminho_config(config_override.clone())?;
310
311 match acao {
312 AcaoVps::Add {
313 name,
314 host,
315 port,
316 user,
317 password,
318 password_stdin,
319 key,
320 key_passphrase,
321 timeout,
322 max_command_chars,
323 max_output_chars,
324 max_chars,
325 sudo_password,
326 sudo_password_stdin,
327 su_password,
328 su_password_stdin,
329 disable_sudo,
330 check,
331 } => {
332 let name = crate::paths::validar_e_normalizar(&name)
334 .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido: {e}")))?;
335 let mut arquivo = carregar(&caminho)?;
336 if arquivo.hosts.contains_key(&name) {
337 return Err(ErroSshCli::VpsDuplicada(name).into());
338 }
339 if password_stdin && (sudo_password_stdin || su_password_stdin) {
340 return Err(ErroSshCli::ArgumentoInvalido(
341 "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
342 )
343 .into());
344 }
345 let senha = if password_stdin {
346 SecretString::from(ler_segredo_stdin()?)
347 } else {
348 SecretString::from(password.unwrap_or_default())
349 };
350 let sudo_s = if sudo_password_stdin {
351 Some(SecretString::from(ler_segredo_stdin()?))
352 } else {
353 sudo_password.map(SecretString::from)
354 };
355 let su_s = if su_password_stdin {
356 Some(SecretString::from(ler_segredo_stdin()?))
357 } else {
358 su_password.map(SecretString::from)
359 };
360 if let Some(ref k) = key {
361 validar_key_path_existe(k)?;
362 }
363 let max_cmd = max_command_chars
365 .as_deref()
366 .or(max_chars.as_deref())
367 .map(parse_limite_chars)
368 .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
369 let max_out = max_output_chars
370 .as_deref()
371 .map(parse_limite_chars)
372 .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
373 let registro = VpsRegistro::novo(
374 name.clone(),
375 host,
376 port,
377 user,
378 senha,
379 key,
380 key_passphrase.map(SecretString::from),
381 Some(timeout),
382 Some(max_cmd),
383 Some(max_out),
384 sudo_s,
385 su_s,
386 disable_sudo,
387 );
388 registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
390 arquivo.hosts.insert(name.clone(), registro);
391 arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
392 salvar(&caminho, &arquivo)?;
393 crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
394 if check {
395 executar_health_check(Some(&name), config_override, formato, false, None, None)
396 .await?;
397 }
398 }
399 AcaoVps::List { json } => {
400 let arquivo = carregar(&caminho)?;
401 let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
402 if usar_json(json, formato) {
404 crate::output::imprimir_lista_json(®istros);
405 } else {
406 crate::output::imprimir_lista_texto(®istros);
407 }
408 }
409 AcaoVps::Remove { nome } => {
410 let mut arquivo = carregar(&caminho)?;
411 if arquivo.hosts.remove(&nome).is_none() {
412 return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
413 }
414 salvar(&caminho, &arquivo)?;
415 limpar_active_se_nome(&caminho, &nome)?;
417 crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
418 }
419 AcaoVps::Edit {
420 nome,
421 host,
422 port,
423 user,
424 password,
425 password_stdin,
426 key,
427 key_passphrase,
428 timeout,
429 max_command_chars,
430 max_output_chars,
431 max_chars,
432 sudo_password,
433 sudo_password_stdin,
434 su_password,
435 su_password_stdin,
436 disable_sudo,
437 } => {
438 let mut arquivo = carregar(&caminho)?;
439 let registro = arquivo
440 .hosts
441 .get_mut(&nome)
442 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
443 if let Some(h) = host {
444 registro.host = h;
445 }
446 if let Some(p) = port {
447 registro.porta = p;
448 }
449 if let Some(u) = user {
450 registro.usuario = u;
451 }
452 if password_stdin {
453 registro.senha = SecretString::from(ler_segredo_stdin()?);
454 } else if let Some(pw) = password {
455 registro.senha = SecretString::from(pw);
456 }
457 if let Some(k) = key {
458 validar_key_path_existe(&k)?;
459 registro.key_path = Some(k);
460 }
461 if let Some(kp) = key_passphrase {
462 registro.key_passphrase = Some(SecretString::from(kp));
463 }
464 if let Some(t) = timeout {
465 registro.timeout_ms = t;
466 }
467 if let Some(m) = max_command_chars.or(max_chars) {
468 registro.max_command_chars = parse_limite_chars(&m);
469 }
470 if let Some(m) = max_output_chars {
471 registro.max_output_chars = parse_limite_chars(&m);
472 }
473 if sudo_password_stdin {
474 registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
475 } else if let Some(sp) = sudo_password {
476 registro.senha_sudo = Some(SecretString::from(sp));
477 }
478 if su_password_stdin {
479 registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
480 } else if let Some(sp) = su_password {
481 registro.senha_su = Some(SecretString::from(sp));
482 }
483 if let Some(d) = disable_sudo {
484 registro.disable_sudo = d;
485 }
486 registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
487 salvar(&caminho, &arquivo)?;
488 crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
489 }
490 AcaoVps::Show { nome, json } => {
491 let arquivo = carregar(&caminho)?;
492 let registro = arquivo
493 .hosts
494 .get(&nome)
495 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
496 if usar_json(json, formato) {
497 crate::output::imprimir_detalhes_json(registro);
498 } else {
499 crate::output::imprimir_detalhes_texto(registro);
500 }
501 }
502 AcaoVps::Path => {
503 crate::output::escrever_linha(&caminho.display().to_string())?;
504 }
505 AcaoVps::Doctor { json } => {
506 executar_doctor(config_override, usar_json(json, formato))?;
507 }
508 AcaoVps::Export {
509 include_secrets,
510 output,
511 } => {
512 executar_export(&caminho, include_secrets, output.as_deref())?;
513 }
514 AcaoVps::Import {
515 file,
516 allow_incomplete,
517 } => {
518 executar_import(&caminho, &file, allow_incomplete)?;
519 }
520 }
521 Ok(())
522}
523
524fn limpar_active_se_nome(caminho_config: &Path, nome: &str) -> Result<()> {
526 let active = caminho_config
527 .parent()
528 .map(|p| p.join("active"))
529 .unwrap_or_else(|| PathBuf::from("active"));
530 if !active.exists() {
531 return Ok(());
532 }
533 let conteudo = std::fs::read_to_string(&active).unwrap_or_default();
534 if conteudo.trim() == nome {
535 let _ = std::fs::remove_file(&active);
536 }
537 Ok(())
538}
539
540fn executar_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
541 let camada = camada_vencedora(config_override.clone())?;
542 let caminho = camada.path.clone();
543 let existe = caminho.exists();
544 let arquivo = carregar(&caminho)?;
545 let kh = KnownHosts::caminho_ao_lado_config(&caminho);
546 let active = caminho
547 .parent()
548 .map(|p| p.join("active"))
549 .unwrap_or_else(|| PathBuf::from("active"));
550 let perms = if existe {
551 #[cfg(unix)]
552 {
553 use std::os::unix::fs::PermissionsExt;
554 format!(
555 "{:o}",
556 std::fs::metadata(&caminho)?.permissions().mode() & 0o777
557 )
558 }
559 #[cfg(not(unix))]
560 {
561 "n/a".to_string()
562 }
563 } else {
564 "ausente".to_string()
565 };
566
567 let seg = crate::secrets::status_segredos()?;
568 if json {
569 let v = serde_json::json!({
570 "layer": camada.nome,
571 "config_path": caminho.display().to_string(),
572 "exists": existe,
573 "permissions": perms,
574 "schema_version": arquivo.schema_version,
575 "hosts": arquivo.hosts.len(),
576 "known_hosts": kh.display().to_string(),
577 "active_file": active.display().to_string(),
578 "secrets_at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
579 "secrets_key_source": seg.fonte.as_str(),
580 "secrets_key_file": seg.key_file_path.display().to_string(),
581 "secrets_plaintext_opt_out": seg.plaintext_opt_out,
582 "telemetry": false,
583 });
584 crate::output::imprimir_json_value(&v)?;
586 } else {
587 let config_path_s = caminho.display().to_string();
588 let kh_s = kh.display().to_string();
589 let active_s = active.display().to_string();
590 let key_file_s = seg.key_file_path.display().to_string();
591 crate::output::imprimir_doctor_texto(
592 camada.nome,
593 &config_path_s,
594 existe,
595 &perms,
596 arquivo.schema_version,
597 arquivo.hosts.len(),
598 &kh_s,
599 &active_s,
600 if seg.cifragem_ativa {
601 "encrypted"
602 } else {
603 "plaintext"
604 },
605 seg.fonte.as_str(),
606 &key_file_s,
607 seg.plaintext_opt_out,
608 );
609 }
610 Ok(())
611}
612
613fn executar_export(caminho: &PathBuf, include_secrets: bool, output: Option<&str>) -> Result<()> {
614 let arquivo = carregar(caminho)?;
615 let mut export = ArquivoConfig {
616 schema_version: arquivo.schema_version,
617 hosts: BTreeMap::new(),
618 };
619 for (k, mut v) in arquivo.hosts {
620 if !include_secrets {
621 v.senha = SecretString::from(String::new());
622 v.senha_sudo = None;
623 v.senha_su = None;
624 v.key_passphrase = None;
625 }
626 export.hosts.insert(k, v);
627 }
628 let texto = toml::to_string_pretty(&export)?;
629 if let Some(path) = output {
630 escrever_atomico(Path::new(path), texto.as_bytes())?;
631 crate::output::imprimir_sucesso(&format!("exportado para {path}"));
632 } else {
633 print!("{texto}");
634 }
635 Ok(())
636}
637
638fn executar_import(caminho: &PathBuf, file: &Path, allow_incomplete: bool) -> Result<()> {
639 let texto = std::fs::read_to_string(file)?;
640 let importado: ArquivoConfig = toml::from_str(&texto)?;
641 let mut atual = carregar(caminho)?;
642 for (k, mut v) in importado.hosts {
643 let nome = crate::paths::validar_e_normalizar(&k).map_err(|e| {
645 ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido no import '{k}': {e}"))
646 })?;
647 v.nome = nome.clone();
648 if let Some(ref key) = v.key_path {
649 if !key.trim().is_empty() {
650 validar_key_path_existe(key)?;
651 }
652 }
653 match v.validar() {
654 Ok(()) => {
655 atual.hosts.insert(nome, v);
656 }
657 Err(msg) if allow_incomplete => {
658 tracing::warn!(host = %nome, %msg, "import incomplete permitido");
660 atual.hosts.insert(nome, v);
661 }
662 Err(msg) => {
663 let redacted = !v.tem_senha() && !v.tem_chave();
665 if redacted {
666 return Err(ErroSshCli::ArgumentoInvalido(format!(
667 "host '{nome}' parece export redacted (sem password/key). \
668 Use `vps export --include-secrets`, complete com `vps edit`, \
669 ou `vps import --allow-incomplete`. Detalhe: {msg}"
670 ))
671 .into());
672 }
673 return Err(ErroSshCli::ArgumentoInvalido(format!(
674 "host '{nome}' inválido no import: {msg}"
675 ))
676 .into());
677 }
678 }
679 }
680 atual.schema_version = modelo::SCHEMA_VERSION_ATUAL;
681 salvar(caminho, &atual)?;
682 crate::output::imprimir_sucesso("importação concluída");
683 Ok(())
684}
685
686pub async fn executar_comando_secrets(
688 acao: AcaoSecrets,
689 config_override: Option<PathBuf>,
690 formato: FormatoSaida,
691) -> Result<()> {
692 crate::secrets::definir_diretorio_config(config_override.clone());
694 match acao {
695 AcaoSecrets::Status { json } => {
696 let seg = crate::secrets::status_segredos()?;
697 let usar_json = json || formato == FormatoSaida::Json;
698 if usar_json {
699 let v = serde_json::json!({
700 "encryption_active": seg.cifragem_ativa,
701 "key_source": seg.fonte.as_str(),
702 "key_file": seg.key_file_path.display().to_string(),
703 "plaintext_opt_out": seg.plaintext_opt_out,
704 "at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
705 });
706 crate::output::imprimir_json_value(&v)?;
707 } else {
708 crate::output::imprimir_sucesso(&format!(
709 "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
710 if seg.cifragem_ativa {
711 "encrypted"
712 } else {
713 "plaintext"
714 },
715 seg.fonte.as_str(),
716 seg.key_file_path.display(),
717 seg.plaintext_opt_out
718 ));
719 }
720 Ok(())
721 }
722 AcaoSecrets::Init { keyring, force } => {
723 let seg = crate::secrets::init_master_key(keyring, force)?;
724 crate::output::imprimir_sucesso(&format!(
725 "master-key pronta (source={}; key_file={})",
726 seg.fonte.as_str(),
727 seg.key_file_path.display()
728 ));
729 Ok(())
730 }
731 AcaoSecrets::Reencrypt => {
732 let caminho = resolver_caminho_config(config_override)?;
733 executar_reencrypt(&caminho)?;
734 Ok(())
735 }
736 }
737}
738
739fn executar_reencrypt(caminho: &PathBuf) -> Result<()> {
741 let (chave, fonte) = crate::secrets::garantir_chave_para_escrita()?;
742 if chave.is_none() {
743 return Err(ErroSshCli::ArgumentoInvalido(
744 "sem master-key; rode `ssh-cli secrets init` ou remova SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
745 .to_string(),
746 )
747 .into());
748 }
749 if let Some(mut k) = chave {
750 use zeroize::Zeroize;
751 k.zeroize();
752 }
753 let arquivo = carregar(caminho)?;
754 salvar(caminho, &arquivo)?;
755 crate::output::imprimir_sucesso(&format!(
756 "reencrypt ok (source={}; hosts={})",
757 fonte.as_str(),
758 arquivo.hosts.len()
759 ));
760 Ok(())
761}
762
763pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
765 let caminho = resolver_caminho_config(config_override)?;
766 let arquivo = carregar(&caminho)?;
767 if !arquivo.hosts.contains_key(nome) {
768 return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
769 }
770
771 let arquivo_ativo = caminho
772 .parent()
773 .map(|p| p.join("active"))
774 .unwrap_or_else(|| PathBuf::from("active"));
775 if let Some(pai) = arquivo_ativo.parent() {
776 std::fs::create_dir_all(pai)?;
777 }
778 let pai = arquivo_ativo
780 .parent()
781 .map(Path::to_path_buf)
782 .unwrap_or_else(|| PathBuf::from("."));
783 let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
784 tmp.write_all(nome.as_bytes())?;
785 tmp.as_file().sync_data()?;
786 tmp.persist(&arquivo_ativo)
787 .map_err(|e| ErroSshCli::Io(e.error))?;
788 crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
789 Ok(())
790}
791
792pub fn buscar_por_nome(
794 config_override: Option<PathBuf>,
795 nome: &str,
796) -> ResultadoSshCli<Option<VpsRegistro>> {
797 let caminho = resolver_caminho_config(config_override)?;
798 let arquivo = carregar(&caminho)?;
799 Ok(arquivo.hosts.get(nome).cloned())
800}
801
802pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
804 let caminho = resolver_caminho_config(config_override)?;
805 let arquivo_ativo = caminho
806 .parent()
807 .map(|p| p.join("active"))
808 .unwrap_or_else(|| PathBuf::from("active"));
809 if !arquivo_ativo.exists() {
810 return Ok(None);
811 }
812 let nome = std::fs::read_to_string(&arquivo_ativo)?;
813 Ok(Some(nome.trim().to_string()))
814}
815
816pub fn construir_configuracao(
818 vps: &VpsRegistro,
819 config_toml: Option<&Path>,
820 replace_host_key: bool,
821) -> ConfiguracaoConexao {
822 let known_hosts_path = config_toml.map(KnownHosts::caminho_ao_lado_config);
823 ConfiguracaoConexao {
824 host: vps.host.clone(),
825 porta: vps.porta,
826 usuario: vps.usuario.clone(),
827 senha: vps.senha.clone(),
828 key_path: vps.key_path.clone(),
829 key_passphrase: vps.key_passphrase.clone(),
830 timeout_ms: vps.timeout_ms,
831 known_hosts_path,
832 replace_host_key,
833 }
834}
835
836#[derive(Debug, Default, Clone)]
838pub struct OpcoesExec {
839 pub password: Option<String>,
841 pub sudo_password: Option<String>,
843 pub su_password: Option<String>,
845 pub timeout: Option<u64>,
847 pub key: Option<String>,
849 pub key_passphrase: Option<String>,
851 pub description: Option<String>,
853 pub replace_host_key: bool,
855 pub disable_sudo: bool,
857}
858
859pub async fn executar_exec(
861 vps_nome: &str,
862 comando: &str,
863 config_override: Option<PathBuf>,
864 formato: FormatoSaida,
865 json: bool,
866 opts: OpcoesExec,
867) -> Result<()> {
868 if crate::signals::cancelado() || crate::signals::terminado() {
869 return Err(anyhow::anyhow!(crate::i18n::t(
870 crate::i18n::Mensagem::OperacaoCancelada
871 )));
872 }
873 let caminho = resolver_caminho_config(config_override)?;
874 let arquivo = carregar(&caminho)?;
875 let vps_base = arquivo
876 .hosts
877 .get(vps_nome)
878 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
879
880 let mut vps = vps_base.clone();
881 aplicar_overrides(
882 &mut vps,
883 opts.password,
884 opts.sudo_password,
885 opts.su_password,
886 opts.timeout,
887 opts.key,
888 opts.key_passphrase,
889 );
890 let cmd = anexar_description(comando, opts.description.as_deref());
891 validar_comando_tamanho(&cmd, vps.max_command_chars)?;
892 let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
893 let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
894 executar_exec_with_client(&vps, &cmd, cliente, formato, json).await
895}
896
897pub async fn executar_exec_with_client(
899 vps: &VpsRegistro,
900 comando: &str,
901 mut cliente: Box<dyn ClienteSshTrait>,
902 formato: FormatoSaida,
903 json: bool,
904) -> Result<()> {
905 if crate::signals::cancelado() || crate::signals::terminado() {
906 return Err(anyhow::anyhow!(crate::i18n::t(
907 crate::i18n::Mensagem::OperacaoCancelada
908 )));
909 }
910 let max_out = limite_efetivo(vps.max_output_chars);
911 let saida = cliente.executar_comando(comando, max_out, None).await?;
912 cliente.desconectar().await?;
913 if formato == FormatoSaida::Json || json {
914 output::imprimir_saida_execucao_json(&saida);
915 } else {
916 output::imprimir_saida_execucao(&saida);
917 }
918 if let Some(code) = saida.exit_code {
919 if code != 0 {
920 return Err(ErroSshCli::ComandoFalhou {
921 exit_code: code,
922 stderr: saida.stderr.clone(),
923 }
924 .into());
925 }
926 }
927 Ok(())
928}
929
930pub async fn executar_sudo_exec(
932 vps_nome: &str,
933 comando: &str,
934 config_override: Option<PathBuf>,
935 formato: FormatoSaida,
936 json: bool,
937 opts: OpcoesExec,
938) -> Result<()> {
939 if crate::signals::cancelado() || crate::signals::terminado() {
940 return Err(anyhow::anyhow!(crate::i18n::t(
941 crate::i18n::Mensagem::OperacaoCancelada
942 )));
943 }
944 let caminho = resolver_caminho_config(config_override)?;
945 let arquivo = carregar(&caminho)?;
946 let vps_base = arquivo
947 .hosts
948 .get(vps_nome)
949 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
950
951 let mut vps = vps_base.clone();
952 aplicar_overrides(
953 &mut vps,
954 opts.password.clone(),
955 opts.sudo_password.clone(),
956 opts.su_password.clone(),
957 opts.timeout,
958 opts.key.clone(),
959 opts.key_passphrase.clone(),
960 );
961 if opts.disable_sudo || vps.disable_sudo {
962 return Err(ErroSshCli::SudoDesabilitado.into());
963 }
964 let cmd = anexar_description(comando, opts.description.as_deref());
965 validar_comando_tamanho(&cmd, vps.max_command_chars)?;
966 let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
967 let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
968 executar_sudo_exec_with_client(&vps, &cmd, cliente, formato, json).await
969}
970
971pub async fn executar_sudo_exec_with_client(
973 vps: &VpsRegistro,
974 comando: &str,
975 mut cliente: Box<dyn ClienteSshTrait>,
976 formato: FormatoSaida,
977 json: bool,
978) -> Result<()> {
979 if crate::signals::cancelado() || crate::signals::terminado() {
980 return Err(anyhow::anyhow!(crate::i18n::t(
981 crate::i18n::Mensagem::OperacaoCancelada
982 )));
983 }
984 if vps.disable_sudo {
985 return Err(ErroSshCli::SudoDesabilitado.into());
986 }
987 let pack = empacotar_sudo(comando, vps.senha_sudo.as_ref());
988 let max_out = limite_efetivo(vps.max_output_chars);
989 let saida = cliente
990 .executar_comando(&pack.comando, max_out, pack.stdin)
991 .await?;
992 cliente.desconectar().await?;
993 if formato == FormatoSaida::Json || json {
994 output::imprimir_saida_execucao_json(&saida);
995 } else {
996 output::imprimir_saida_execucao(&saida);
997 }
998 if let Some(code) = saida.exit_code {
999 if code != 0 {
1000 return Err(ErroSshCli::ComandoFalhou {
1001 exit_code: code,
1002 stderr: saida.stderr.clone(),
1003 }
1004 .into());
1005 }
1006 }
1007 Ok(())
1008}
1009
1010pub async fn executar_su_exec(
1012 vps_nome: &str,
1013 comando: &str,
1014 config_override: Option<PathBuf>,
1015 formato: FormatoSaida,
1016 json: bool,
1017 opts: OpcoesExec,
1018) -> Result<()> {
1019 if crate::signals::cancelado() || crate::signals::terminado() {
1020 return Err(anyhow::anyhow!(crate::i18n::t(
1021 crate::i18n::Mensagem::OperacaoCancelada
1022 )));
1023 }
1024 let caminho = resolver_caminho_config(config_override)?;
1025 let arquivo = carregar(&caminho)?;
1026 let vps_base = arquivo
1027 .hosts
1028 .get(vps_nome)
1029 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
1030
1031 let mut vps = vps_base.clone();
1032 aplicar_overrides(
1033 &mut vps,
1034 opts.password,
1035 opts.sudo_password,
1036 opts.su_password,
1037 opts.timeout,
1038 opts.key,
1039 opts.key_passphrase,
1040 );
1041 if opts.disable_sudo || vps.disable_sudo {
1042 return Err(ErroSshCli::SudoDesabilitado.into());
1043 }
1044 let senha_su = vps.senha_su.clone().ok_or(ErroSshCli::SenhaSuAusente)?;
1045 let cmd = anexar_description(comando, opts.description.as_deref());
1046 validar_comando_tamanho(&cmd, vps.max_command_chars)?;
1047 let pack = empacotar_su(&cmd, &senha_su);
1048 let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
1049 let mut cliente: Box<dyn ClienteSshTrait> =
1050 <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1051 let max_out = limite_efetivo(vps.max_output_chars);
1052 let saida = cliente
1053 .executar_comando(&pack.comando, max_out, pack.stdin)
1054 .await?;
1055 cliente.desconectar().await?;
1056 if formato == FormatoSaida::Json || json {
1057 output::imprimir_saida_execucao_json(&saida);
1058 } else {
1059 output::imprimir_saida_execucao(&saida);
1060 }
1061 if let Some(code) = saida.exit_code {
1062 if code != 0 {
1063 return Err(ErroSshCli::ComandoFalhou {
1064 exit_code: code,
1065 stderr: saida.stderr.clone(),
1066 }
1067 .into());
1068 }
1069 }
1070 Ok(())
1071}
1072
1073pub async fn executar_health_check(
1075 vps_nome: Option<&str>,
1076 config_override: Option<PathBuf>,
1077 formato: FormatoSaida,
1078 json_local: bool,
1079 password_override: Option<String>,
1080 timeout_override: Option<u64>,
1081) -> Result<()> {
1082 let _ = json_local; if crate::signals::cancelado() || crate::signals::terminado() {
1084 return Err(anyhow::anyhow!(crate::i18n::t(
1085 crate::i18n::Mensagem::OperacaoCancelada
1086 )));
1087 }
1088 let nome_resolvido: String = match vps_nome {
1089 Some(n) => n.to_string(),
1090 None => {
1091 let ativa = ler_vps_ativa(config_override.clone())?;
1093 ativa.ok_or(ErroSshCli::NenhumaVpsAtiva)?
1094 }
1095 };
1096 let caminho = resolver_caminho_config(config_override)?;
1097 let arquivo = carregar(&caminho)?;
1098 let vps_base = arquivo
1099 .hosts
1100 .get(&nome_resolvido)
1101 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
1102
1103 let mut vps = vps_base.clone();
1104 aplicar_overrides(
1106 &mut vps,
1107 password_override,
1108 None,
1109 None,
1110 timeout_override,
1111 None,
1112 None,
1113 );
1114 let cfg = construir_configuracao(&vps, Some(&caminho), false);
1115 let inicio = std::time::Instant::now();
1116 let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1117 let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1118 cliente.desconectar().await?;
1119
1120 if usar_json(json_local, formato) {
1121 output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
1122 } else {
1123 output::imprimir_health_check(&nome_resolvido, latencia_ms);
1124 }
1125 Ok(())
1126}
1127
1128#[cfg(test)]
1129mod testes {
1130 use super::*;
1131 use secrecy::ExposeSecret;
1132 use tempfile::TempDir;
1133
1134 fn reg_min() -> VpsRegistro {
1135 VpsRegistro::novo(
1136 "srv".into(),
1137 "host.example.com".into(),
1138 2222,
1139 "admin".into(),
1140 SecretString::from("pass".to_string()),
1141 None,
1142 None,
1143 Some(60_000),
1144 Some(1_000),
1145 Some(50_000),
1146 None,
1147 None,
1148 false,
1149 )
1150 }
1151
1152 #[test]
1153 fn arquivo_vazio_serializa_com_schema() {
1154 let arq = ArquivoConfig {
1155 schema_version: modelo::SCHEMA_VERSION_ATUAL,
1156 hosts: BTreeMap::new(),
1157 };
1158 let texto = toml::to_string(&arq).unwrap();
1159 assert!(texto.contains("schema_version = 2"));
1160 }
1161
1162 #[test]
1163 fn parse_limite_none() {
1164 assert_eq!(parse_limite_chars("none"), 0);
1165 assert_eq!(parse_limite_chars("0"), 0);
1166 assert_eq!(parse_limite_chars("1000"), 1000);
1167 }
1168
1169 #[test]
1170 fn construir_configuracao_copia_campos() {
1171 let registro = reg_min();
1172 let cfg = construir_configuracao(®istro, None, false);
1173 assert_eq!(cfg.host, "host.example.com");
1174 assert_eq!(cfg.porta, 2222);
1175 assert_eq!(cfg.usuario, "admin");
1176 assert_eq!(cfg.timeout_ms, 60_000);
1177 assert!(cfg.known_hosts_path.is_none());
1178 }
1179
1180 #[test]
1181 #[serial_test::serial]
1182 fn salvar_atomico_roundtrip() {
1183 let tmp = TempDir::new().unwrap();
1184 crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
1185 unsafe {
1186 std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1187 }
1188 let path = tmp.path().join("config.toml");
1189 let mut arq = ArquivoConfig {
1190 schema_version: 2,
1191 hosts: BTreeMap::new(),
1192 };
1193 arq.hosts.insert("a".into(), reg_min());
1194 salvar(&path, &arq).unwrap();
1195 let lido = carregar(&path).unwrap();
1196 assert_eq!(lido.hosts.len(), 1);
1197 assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1198 #[cfg(unix)]
1199 {
1200 use std::os::unix::fs::PermissionsExt;
1201 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1202 assert_eq!(mode, 0o600);
1203 let lock = path.with_extension("toml.lock");
1204 if lock.exists() {
1205 let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1206 assert_eq!(lm, 0o600);
1207 }
1208 }
1209 unsafe {
1210 std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1211 }
1212 crate::secrets::definir_diretorio_config(None);
1213 }
1214
1215 #[test]
1216 fn resolver_caminho_config_com_override_diretorio() {
1217 let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1218 assert_eq!(
1219 resultado.unwrap(),
1220 PathBuf::from("/tmp/test-dir/config.toml")
1221 );
1222 }
1223
1224 #[test]
1225 fn validar_comando_longo() {
1226 let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1227 assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1228 }
1229
1230 #[test]
1231 fn empacotar_sudo_integrado() {
1232 let pack = empacotar_sudo("ls -la", None);
1233 assert_eq!(pack.comando, "sudo -n sh -c 'ls -la'");
1234 assert!(pack.stdin.is_none());
1235 }
1236
1237 #[test]
1238 fn aplicar_overrides_senha() {
1239 let mut v = reg_min();
1240 aplicar_overrides(
1241 &mut v,
1242 Some("nova".into()),
1243 Some("sudo".into()),
1244 None,
1245 Some(1000),
1246 Some("/k".into()),
1247 None,
1248 );
1249 assert_eq!(v.senha.expose_secret(), "nova");
1250 assert_eq!(v.timeout_ms, 1000);
1251 assert_eq!(v.key_path.as_deref(), Some("/k"));
1252 }
1253
1254 #[tokio::test]
1255 async fn executar_sudo_exec_with_client_ok() {
1256 use crate::ssh::cliente::mocks::MockClienteSsh;
1257 use crate::ssh::cliente::SaidaExecucao;
1258 let mut mock = MockClienteSsh::new();
1259 mock.expect_executar_comando().returning(|c, _, stdin| {
1260 assert!(c.contains("sudo -n sh -c"));
1261 assert!(stdin.is_none());
1262 Ok(SaidaExecucao {
1263 stdout: "ok".into(),
1264 stderr: String::new(),
1265 exit_code: Some(0),
1266 truncado_stdout: false,
1267 truncado_stderr: false,
1268 duracao_ms: 1,
1269 })
1270 });
1271 mock.expect_desconectar().returning(|| Ok(()));
1272
1273 let vps = reg_min();
1274 executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1275 .await
1276 .unwrap();
1277 }
1278}