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