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
258pub(crate) fn aplicar_overrides(
262 vps: &mut VpsRegistro,
263 password_override: Option<String>,
264 sudo_password_override: Option<String>,
265 su_password_override: Option<String>,
266 timeout_override: Option<u64>,
267 key_path_override: Option<String>,
268 key_passphrase_override: Option<String>,
269) {
270 if let Some(pwd) = password_override {
271 vps.senha = SecretString::from(pwd);
272 }
273 if let Some(spwd) = sudo_password_override {
274 vps.senha_sudo = Some(SecretString::from(spwd));
275 }
276 if let Some(sp) = su_password_override {
277 vps.senha_su = Some(SecretString::from(sp));
278 }
279 if let Some(t) = timeout_override {
280 vps.timeout_ms = t;
281 }
282 if let Some(k) = key_path_override {
283 vps.key_path = Some(k);
284 }
285 if let Some(kp) = key_passphrase_override {
286 vps.key_passphrase = Some(SecretString::from(kp));
287 }
288}
289
290fn validar_comando_tamanho(comando: &str, max_command_chars: usize) -> ResultadoSshCli<()> {
291 let lim = limite_efetivo(max_command_chars);
292 let len = comando.chars().count();
293 if len > lim {
294 return Err(ErroSshCli::ComandoMuitoLongo {
295 max: max_command_chars,
296 len,
297 });
298 }
299 if comando.trim().is_empty() {
300 return Err(ErroSshCli::ArgumentoInvalido("comando vazio".to_string()));
301 }
302 Ok(())
303}
304
305pub async fn executar_comando_vps(
307 acao: AcaoVps,
308 config_override: Option<PathBuf>,
309 formato: FormatoSaida,
310) -> Result<()> {
311 let caminho = resolver_caminho_config(config_override.clone())?;
312
313 match acao {
314 AcaoVps::Add {
315 name,
316 host,
317 port,
318 user,
319 password,
320 password_stdin,
321 key,
322 key_passphrase,
323 timeout,
324 max_command_chars,
325 max_output_chars,
326 max_chars,
327 sudo_password,
328 sudo_password_stdin,
329 su_password,
330 su_password_stdin,
331 disable_sudo,
332 check,
333 } => {
334 let name = crate::paths::validar_e_normalizar(&name)
336 .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido: {e}")))?;
337 let mut arquivo = carregar(&caminho)?;
338 if arquivo.hosts.contains_key(&name) {
339 return Err(ErroSshCli::VpsDuplicada(name).into());
340 }
341 if password_stdin && (sudo_password_stdin || su_password_stdin) {
342 return Err(ErroSshCli::ArgumentoInvalido(
343 "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
344 )
345 .into());
346 }
347 let senha = if password_stdin {
348 SecretString::from(ler_segredo_stdin()?)
349 } else {
350 SecretString::from(password.unwrap_or_default())
351 };
352 let sudo_s = if sudo_password_stdin {
353 Some(SecretString::from(ler_segredo_stdin()?))
354 } else {
355 sudo_password.map(SecretString::from)
356 };
357 let su_s = if su_password_stdin {
358 Some(SecretString::from(ler_segredo_stdin()?))
359 } else {
360 su_password.map(SecretString::from)
361 };
362 if let Some(ref k) = key {
363 validar_key_path_existe(k)?;
364 }
365 let max_cmd = max_command_chars
367 .as_deref()
368 .or(max_chars.as_deref())
369 .map(parse_limite_chars)
370 .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
371 let max_out = max_output_chars
372 .as_deref()
373 .map(parse_limite_chars)
374 .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
375 let registro = VpsRegistro::novo(
376 name.clone(),
377 host,
378 port,
379 user,
380 senha,
381 key,
382 key_passphrase.map(SecretString::from),
383 Some(timeout),
384 Some(max_cmd),
385 Some(max_out),
386 sudo_s,
387 su_s,
388 disable_sudo,
389 );
390 registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
392 arquivo.hosts.insert(name.clone(), registro);
393 arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
394 salvar(&caminho, &arquivo)?;
395 crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
396 if check {
397 executar_health_check(
398 Some(&name),
399 config_override,
400 formato,
401 false,
402 None,
403 None,
404 None,
405 None,
406 false,
407 )
408 .await?;
409 }
410 }
411 AcaoVps::List { json } => {
412 let arquivo = carregar(&caminho)?;
413 let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
414 if usar_json(json, formato) {
416 crate::output::imprimir_lista_json(®istros);
417 } else {
418 crate::output::imprimir_lista_texto(®istros);
419 }
420 }
421 AcaoVps::Remove { nome } => {
422 let mut arquivo = carregar(&caminho)?;
423 if arquivo.hosts.remove(&nome).is_none() {
424 return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
425 }
426 salvar(&caminho, &arquivo)?;
427 limpar_active_se_nome(&caminho, &nome)?;
429 crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
430 }
431 AcaoVps::Edit {
432 nome,
433 host,
434 port,
435 user,
436 password,
437 password_stdin,
438 key,
439 key_passphrase,
440 timeout,
441 max_command_chars,
442 max_output_chars,
443 max_chars,
444 sudo_password,
445 sudo_password_stdin,
446 su_password,
447 su_password_stdin,
448 disable_sudo,
449 } => {
450 let mut arquivo = carregar(&caminho)?;
451 let registro = arquivo
452 .hosts
453 .get_mut(&nome)
454 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
455 if let Some(h) = host {
456 registro.host = h;
457 }
458 if let Some(p) = port {
459 registro.porta = p;
460 }
461 if let Some(u) = user {
462 registro.usuario = u;
463 }
464 if password_stdin {
465 registro.senha = SecretString::from(ler_segredo_stdin()?);
466 } else if let Some(pw) = password {
467 registro.senha = SecretString::from(pw);
468 }
469 if let Some(k) = key {
470 validar_key_path_existe(&k)?;
471 registro.key_path = Some(k);
472 }
473 if let Some(kp) = key_passphrase {
474 registro.key_passphrase = Some(SecretString::from(kp));
475 }
476 if let Some(t) = timeout {
477 registro.timeout_ms = t;
478 }
479 if let Some(m) = max_command_chars.or(max_chars) {
480 registro.max_command_chars = parse_limite_chars(&m);
481 }
482 if let Some(m) = max_output_chars {
483 registro.max_output_chars = parse_limite_chars(&m);
484 }
485 if sudo_password_stdin {
486 registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
487 } else if let Some(sp) = sudo_password {
488 registro.senha_sudo = Some(SecretString::from(sp));
489 }
490 if su_password_stdin {
491 registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
492 } else if let Some(sp) = su_password {
493 registro.senha_su = Some(SecretString::from(sp));
494 }
495 if let Some(d) = disable_sudo {
496 registro.disable_sudo = d;
497 }
498 registro.validar().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
1085#[allow(clippy::too_many_arguments)]
1088pub async fn executar_health_check(
1089 vps_nome: Option<&str>,
1090 config_override: Option<PathBuf>,
1091 formato: FormatoSaida,
1092 json_local: bool,
1093 password_override: Option<String>,
1094 timeout_override: Option<u64>,
1095 key_override: Option<String>,
1096 key_passphrase_override: Option<String>,
1097 replace_host_key: bool,
1098) -> Result<()> {
1099 if json_local || formato == FormatoSaida::Json {
1101 crate::output::definir_json_erros(true);
1102 }
1103 if crate::signals::cancelado() || crate::signals::terminado() {
1104 return Err(anyhow::anyhow!(crate::i18n::t(
1105 crate::i18n::Mensagem::OperacaoCancelada
1106 )));
1107 }
1108 let nome_resolvido: String = match vps_nome {
1109 Some(n) => n.to_string(),
1110 None => {
1111 let ativa = ler_vps_ativa(config_override.clone())?;
1113 ativa.ok_or(ErroSshCli::NenhumaVpsAtiva)?
1114 }
1115 };
1116 let caminho = resolver_caminho_config(config_override)?;
1117 let arquivo = carregar(&caminho)?;
1118 let vps_base = arquivo
1119 .hosts
1120 .get(&nome_resolvido)
1121 .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
1122
1123 let mut vps = vps_base.clone();
1124 aplicar_overrides(
1127 &mut vps,
1128 password_override,
1129 None,
1130 None,
1131 timeout_override,
1132 key_override,
1133 key_passphrase_override,
1134 );
1135 let cfg = construir_configuracao(&vps, Some(&caminho), replace_host_key);
1137 let inicio = std::time::Instant::now();
1138 let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1139 let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1140 cliente.desconectar().await?;
1141
1142 if usar_json(json_local, formato) {
1143 output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
1144 } else {
1145 output::imprimir_health_check(&nome_resolvido, latencia_ms);
1146 }
1147 Ok(())
1148}
1149
1150#[cfg(test)]
1151mod testes {
1152 use super::*;
1153 use secrecy::ExposeSecret;
1154 use tempfile::TempDir;
1155
1156 fn reg_min() -> VpsRegistro {
1157 VpsRegistro::novo(
1158 "srv".into(),
1159 "host.example.com".into(),
1160 2222,
1161 "admin".into(),
1162 SecretString::from("pass".to_string()),
1163 None,
1164 None,
1165 Some(60_000),
1166 Some(1_000),
1167 Some(50_000),
1168 None,
1169 None,
1170 false,
1171 )
1172 }
1173
1174 #[test]
1175 fn arquivo_vazio_serializa_com_schema() {
1176 let arq = ArquivoConfig {
1177 schema_version: modelo::SCHEMA_VERSION_ATUAL,
1178 hosts: BTreeMap::new(),
1179 };
1180 let texto = toml::to_string(&arq).unwrap();
1181 assert!(texto.contains("schema_version = 2"));
1182 }
1183
1184 #[test]
1185 fn parse_limite_none() {
1186 assert_eq!(parse_limite_chars("none"), 0);
1187 assert_eq!(parse_limite_chars("0"), 0);
1188 assert_eq!(parse_limite_chars("1000"), 1000);
1189 }
1190
1191 #[test]
1192 fn construir_configuracao_copia_campos() {
1193 let registro = reg_min();
1194 let cfg = construir_configuracao(®istro, None, false);
1195 assert_eq!(cfg.host, "host.example.com");
1196 assert_eq!(cfg.porta, 2222);
1197 assert_eq!(cfg.usuario, "admin");
1198 assert_eq!(cfg.timeout_ms, 60_000);
1199 assert!(cfg.known_hosts_path.is_none());
1200 }
1201
1202 #[test]
1203 #[serial_test::serial]
1204 fn salvar_atomico_roundtrip() {
1205 let tmp = TempDir::new().unwrap();
1206 crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
1207 unsafe {
1208 std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1209 }
1210 let path = tmp.path().join("config.toml");
1211 let mut arq = ArquivoConfig {
1212 schema_version: 2,
1213 hosts: BTreeMap::new(),
1214 };
1215 arq.hosts.insert("a".into(), reg_min());
1216 salvar(&path, &arq).unwrap();
1217 let lido = carregar(&path).unwrap();
1218 assert_eq!(lido.hosts.len(), 1);
1219 assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1220 #[cfg(unix)]
1221 {
1222 use std::os::unix::fs::PermissionsExt;
1223 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1224 assert_eq!(mode, 0o600);
1225 let lock = path.with_extension("toml.lock");
1226 if lock.exists() {
1227 let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1228 assert_eq!(lm, 0o600);
1229 }
1230 }
1231 unsafe {
1232 std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1233 }
1234 crate::secrets::definir_diretorio_config(None);
1235 }
1236
1237 #[test]
1238 fn resolver_caminho_config_com_override_diretorio() {
1239 let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1240 assert_eq!(
1241 resultado.unwrap(),
1242 PathBuf::from("/tmp/test-dir/config.toml")
1243 );
1244 }
1245
1246 #[test]
1247 fn validar_comando_longo() {
1248 let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1249 assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1250 }
1251
1252 #[test]
1253 fn empacotar_sudo_integrado() {
1254 let pack = empacotar_sudo("ls -la", None);
1255 assert_eq!(pack.comando, "sudo -n sh -c 'ls -la'");
1256 assert!(pack.stdin.is_none());
1257 }
1258
1259 #[test]
1260 fn aplicar_overrides_senha() {
1261 let mut v = reg_min();
1262 aplicar_overrides(
1263 &mut v,
1264 Some("nova".into()),
1265 Some("sudo".into()),
1266 None,
1267 Some(1000),
1268 Some("/k".into()),
1269 None,
1270 );
1271 assert_eq!(v.senha.expose_secret(), "nova");
1272 assert_eq!(v.timeout_ms, 1000);
1273 assert_eq!(v.key_path.as_deref(), Some("/k"));
1274 }
1275
1276 #[tokio::test]
1277 async fn executar_sudo_exec_with_client_ok() {
1278 use crate::ssh::cliente::mocks::MockClienteSsh;
1279 use crate::ssh::cliente::SaidaExecucao;
1280 let mut mock = MockClienteSsh::new();
1281 mock.expect_executar_comando().returning(|c, _, stdin| {
1282 assert!(c.contains("sudo -n sh -c"));
1283 assert!(stdin.is_none());
1284 Ok(SaidaExecucao {
1285 stdout: "ok".into(),
1286 stderr: String::new(),
1287 exit_code: Some(0),
1288 truncado_stdout: false,
1289 truncado_stderr: false,
1290 duracao_ms: 1,
1291 })
1292 });
1293 mock.expect_desconectar().returning(|| Ok(()));
1294
1295 let vps = reg_min();
1296 executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1297 .await
1298 .unwrap();
1299 }
1300}