Skip to main content

ssh_cli/
cli.rs

1//! Definição de argumentos CLI via `clap` derive e dispatcher.
2//!
3//! 1. CRUD de VPS — `vps add|list|remove|edit|show|path|doctor|export|import`
4//! 2. `connect` — grava arquivo irmão `active` (não campo TOML)
5//! 3. Execução one-shot — `exec|sudo-exec|su-exec|scp|tunnel|health-check`
6//! 4. `secrets` — master-key status/init/reencrypt (cifragem at-rest default)
7//! 5. Completions
8//!
9//! ZERO `.env` em runtime. ZERO telemetria. Ciclo one-shot: nascer → executar → morrer.
10
11use anyhow::Result;
12use clap::{Parser, Subcommand};
13use clap_complete::Shell;
14use std::path::PathBuf;
15
16/// Formato de saída suportado pela CLI.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
18pub enum FormatoSaida {
19    /// Texto legível por humanos (padrão).
20    #[default]
21    Text,
22    /// JSON estruturado.
23    Json,
24}
25
26/// Argumentos globais do ssh-cli.
27#[derive(Debug, Parser)]
28#[command(
29    name = "ssh-cli",
30    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
31    about = "CLI Rust one-shot multi-host XDG para LLMs operarem servidores via SSH.",
32    long_about = "ssh-cli: binário leve one-shot (nascer→executar→morrer). Multi-host em storage XDG sem .env. \
33Auth por senha ou chave. Sem telemetria."
34)]
35pub struct Argumentos {
36    /// Força o idioma da CLI (ex.: `pt-BR`, `en-US`).
37    #[arg(long, global = true, value_name = "LOCALE")]
38    pub lang: Option<String>,
39
40    /// Aumenta a verbosidade de logs em stderr.
41    #[arg(short, long, global = true)]
42    pub verbose: bool,
43
44    /// Suprime output não-JSON (modo silencioso).
45    #[arg(short, long, global = true)]
46    pub quiet: bool,
47
48    /// Override do diretório de configuração (útil para testes).
49    #[arg(long, global = true, value_name = "DIR")]
50    pub config_dir: Option<PathBuf>,
51
52    /// Desativa cores no output.
53    #[arg(long, global = true)]
54    pub no_color: bool,
55
56    /// Formato global de saída (text, json). Se omitido: JSON quando stdout não é TTY.
57    #[arg(long, global = true, value_enum)]
58    pub output_format: Option<FormatoSaida>,
59
60    /// Desabilita sudo-exec/su-exec nesta invocação (alias --disableSudo).
61    #[arg(long, global = true, alias = "disableSudo")]
62    pub disable_sudo: bool,
63
64    /// Substitui host key divergente no known_hosts TOFU.
65    #[arg(long, global = true)]
66    pub replace_host_key: bool,
67
68    /// Subcomando a executar.
69    #[command(subcommand)]
70    pub comando: Comando,
71}
72
73/// Subcomandos de primeiro nível.
74#[derive(Debug, Subcommand)]
75pub enum Comando {
76    /// Gerencia VPSs cadastradas.
77    Vps {
78        /// Ação específica do CRUD de VPS.
79        #[command(subcommand)]
80        acao: AcaoVps,
81    },
82
83    /// Define a VPS ativa (grava arquivo irmão `active` no diretório de config).
84    Connect {
85        /// Nome da VPS previamente adicionada via `vps add`.
86        nome: String,
87    },
88
89    /// Executa um comando na VPS via SSH (stdout/stderr capturados).
90    Exec {
91        /// Nome da VPS.
92        vps_nome: String,
93        /// Comando shell a executar.
94        comando: String,
95        /// Saída em JSON.
96        #[arg(long)]
97        json: bool,
98        /// Override de senha SSH.
99        #[arg(long)]
100        password: Option<String>,
101        /// Lê senha SSH de stdin.
102        #[arg(long)]
103        password_stdin: bool,
104        /// Override de caminho de chave privada.
105        #[arg(long)]
106        key: Option<String>,
107        /// Passphrase da chave (runtime).
108        #[arg(long)]
109        key_passphrase: Option<String>,
110        /// Lê passphrase da chave de stdin.
111        #[arg(long)]
112        key_passphrase_stdin: bool,
113        /// Override de timeout em milissegundos.
114        #[arg(long)]
115        timeout: Option<u64>,
116        /// Comentário shell anexado (comentário shell para auditoria).
117        #[arg(long)]
118        description: Option<String>,
119    },
120
121    /// Executa um comando com `sudo` (packing `sh -c` seguro).
122    SudoExec {
123        /// Nome da VPS.
124        vps_nome: String,
125        /// Comando shell.
126        comando: String,
127        /// Saída em JSON.
128        #[arg(long)]
129        json: bool,
130        /// Override de senha SSH.
131        #[arg(long)]
132        password: Option<String>,
133        /// Lê senha SSH de stdin.
134        #[arg(long)]
135        password_stdin: bool,
136        /// Override de senha sudo.
137        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
138        sudo_password: Option<String>,
139        /// Lê senha sudo de stdin.
140        #[arg(long)]
141        sudo_password_stdin: bool,
142        /// Override de chave.
143        #[arg(long)]
144        key: Option<String>,
145        /// Passphrase da chave (runtime).
146        #[arg(long)]
147        key_passphrase: Option<String>,
148        /// Lê passphrase da chave de stdin.
149        #[arg(long)]
150        key_passphrase_stdin: bool,
151        /// Override de timeout em milissegundos.
152        #[arg(long)]
153        timeout: Option<u64>,
154        /// Comentário shell anexado.
155        #[arg(long)]
156        description: Option<String>,
157    },
158
159    /// Executa um comando com elevação `su -` one-shot.
160    SuExec {
161        /// Nome da VPS.
162        vps_nome: String,
163        /// Comando shell.
164        comando: String,
165        /// Saída em JSON.
166        #[arg(long)]
167        json: bool,
168        /// Override de senha SSH.
169        #[arg(long)]
170        password: Option<String>,
171        /// Override de senha su.
172        #[arg(long, alias = "suPassword", alias = "su_password")]
173        su_password: Option<String>,
174        /// Lê senha su de stdin.
175        #[arg(long)]
176        su_password_stdin: bool,
177        /// Override de chave.
178        #[arg(long)]
179        key: Option<String>,
180        /// Passphrase da chave (runtime).
181        #[arg(long)]
182        key_passphrase: Option<String>,
183        /// Lê passphrase da chave de stdin.
184        #[arg(long)]
185        key_passphrase_stdin: bool,
186        /// Override de timeout.
187        #[arg(long)]
188        timeout: Option<u64>,
189        /// Comentário shell anexado.
190        #[arg(long)]
191        description: Option<String>,
192    },
193
194    /// Transferência de arquivos via SCP (upload/download).
195    Scp {
196        /// Ação específica do SCP.
197        #[command(subcommand)]
198        acao: AcaoScp,
199    },
200
201    /// Tunnel SSH com deadline obrigatório (one-shot limitado).
202    Tunnel {
203        /// Nome da VPS.
204        vps_nome: String,
205        /// Porta local.
206        porta_local: u16,
207        /// Host remoto.
208        host_remoto: String,
209        /// Porta remota.
210        porta_remota: u16,
211        /// Timeout obrigatório do tunnel em milissegundos.
212        #[arg(long)]
213        timeout_ms: u64,
214        /// Override de senha SSH.
215        #[arg(long)]
216        password: Option<String>,
217        /// Override de chave.
218        #[arg(long)]
219        key: Option<String>,
220    },
221
222    /// Verifica conectividade SSH com uma VPS.
223    HealthCheck {
224        /// Nome da VPS (usa ativa se omitido).
225        vps_nome: Option<String>,
226        /// Override de senha SSH.
227        #[arg(long)]
228        password: Option<String>,
229    },
230
231    /// Gerencia master-key e cifragem at-rest de secrets (one-shot).
232    Secrets {
233        /// Ação de secrets.
234        #[command(subcommand)]
235        acao: AcaoSecrets,
236    },
237
238    /// Gera completions de shell.
239    Completions {
240        /// Shell alvo.
241        #[arg(value_enum)]
242        shell: Shell,
243    },
244}
245
246/// Ações do subcomando `vps`.
247#[derive(Debug, Subcommand)]
248pub enum AcaoVps {
249    /// Adiciona uma nova VPS ao registro.
250    Add {
251        /// Nome único da VPS.
252        #[arg(long)]
253        name: String,
254        /// Hostname ou IP.
255        #[arg(long)]
256        host: String,
257        /// Porta SSH.
258        #[arg(long, default_value_t = 22)]
259        port: u16,
260        /// Usuário SSH.
261        #[arg(long)]
262        user: String,
263        /// Senha SSH.
264        #[arg(long)]
265        password: Option<String>,
266        /// Lê senha de stdin.
267        #[arg(long)]
268        password_stdin: bool,
269        /// Caminho da chave privada OpenSSH.
270        #[arg(long)]
271        key: Option<String>,
272        /// Passphrase da chave.
273        #[arg(long)]
274        key_passphrase: Option<String>,
275        /// Timeout em milissegundos (default 60000).
276        #[arg(long, default_value_t = 60_000)]
277        timeout: u64,
278        /// Limite de caracteres do comando (entrada). Alias legado: maxChars.
279        #[arg(long)]
280        max_command_chars: Option<String>,
281        /// Limite de caracteres de saída.
282        #[arg(long)]
283        max_output_chars: Option<String>,
284        /// Alias legado: mapeia para max_command_chars .
285        #[arg(long, alias = "maxChars")]
286        max_chars: Option<String>,
287        /// Senha para `sudo`.
288        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
289        sudo_password: Option<String>,
290        /// Lê senha sudo de stdin.
291        #[arg(long)]
292        sudo_password_stdin: bool,
293        /// Senha para `su -`.
294        #[arg(long, alias = "suPassword", alias = "su_password")]
295        su_password: Option<String>,
296        /// Lê senha su de stdin.
297        #[arg(long)]
298        su_password_stdin: bool,
299        /// Desabilita sudo/su neste host.
300        #[arg(long, default_value_t = false)]
301        disable_sudo: bool,
302        /// Roda health-check após add.
303        #[arg(long)]
304        check: bool,
305    },
306
307    /// Lista todas as VPSs (senhas mascaradas).
308    List {
309        /// Saída em JSON.
310        #[arg(long)]
311        json: bool,
312    },
313
314    /// Remove uma VPS do registro.
315    Remove {
316        /// Nome da VPS a remover.
317        nome: String,
318    },
319
320    /// Edita campos de uma VPS existente.
321    Edit {
322        /// Nome da VPS a editar.
323        nome: String,
324        /// Novo hostname/IP.
325        #[arg(long)]
326        host: Option<String>,
327        /// Nova porta SSH.
328        #[arg(long)]
329        port: Option<u16>,
330        /// Novo usuário.
331        #[arg(long)]
332        user: Option<String>,
333        /// Nova senha.
334        #[arg(long)]
335        password: Option<String>,
336        /// Lê senha de stdin.
337        #[arg(long)]
338        password_stdin: bool,
339        /// Nova chave.
340        #[arg(long)]
341        key: Option<String>,
342        /// Nova passphrase.
343        #[arg(long)]
344        key_passphrase: Option<String>,
345        /// Novo timeout.
346        #[arg(long)]
347        timeout: Option<u64>,
348        /// Novo max command chars.
349        #[arg(long)]
350        max_command_chars: Option<String>,
351        /// Novo max output chars.
352        #[arg(long)]
353        max_output_chars: Option<String>,
354        /// Alias legado maxChars → command.
355        #[arg(long, alias = "maxChars")]
356        max_chars: Option<String>,
357        /// Nova senha sudo.
358        #[arg(long, alias = "sudoPassword", alias = "sudo_password")]
359        sudo_password: Option<String>,
360        /// Lê senha sudo de stdin.
361        #[arg(long)]
362        sudo_password_stdin: bool,
363        /// Nova senha su.
364        #[arg(long, alias = "suPassword", alias = "su_password")]
365        su_password: Option<String>,
366        /// Lê senha su de stdin.
367        #[arg(long)]
368        su_password_stdin: bool,
369        /// Define disable_sudo.
370        #[arg(long)]
371        disable_sudo: Option<bool>,
372    },
373
374    /// Exibe detalhes de uma VPS (senhas mascaradas).
375    Show {
376        /// Nome da VPS.
377        nome: String,
378        /// Saída em JSON.
379        #[arg(long)]
380        json: bool,
381    },
382
383    /// Exibe o caminho do arquivo de configuração.
384    Path,
385
386    /// Diagnóstico de camadas XDG / path / schema.
387    Doctor {
388        /// Saída em JSON.
389        #[arg(long)]
390        json: bool,
391    },
392
393    /// Exporta hosts (senhas redacted por default).
394    Export {
395        /// Inclui segredos no export.
396        #[arg(long)]
397        include_secrets: bool,
398        /// Arquivo de saída (stdout se omitido).
399        #[arg(long, short)]
400        output: Option<String>,
401    },
402
403    /// Importa hosts de um TOML.
404    Import {
405        /// Arquivo de origem.
406        #[arg(long)]
407        file: PathBuf,
408    },
409}
410
411/// Ações do subcomando `scp`.
412#[derive(Debug, Subcommand)]
413pub enum AcaoScp {
414    /// Upload de arquivo local para remote.
415    Upload {
416        /// Nome da VPS.
417        vps_nome: String,
418        /// Caminho local.
419        local: PathBuf,
420        /// Caminho remote.
421        remote: PathBuf,
422        /// Override de senha SSH.
423        #[arg(long)]
424        password: Option<String>,
425    },
426
427    /// Download de arquivo remote para local.
428    Download {
429        /// Nome da VPS.
430        vps_nome: String,
431        /// Caminho remote.
432        remote: PathBuf,
433        /// Caminho local.
434        local: PathBuf,
435        /// Override de senha SSH.
436        #[arg(long)]
437        password: Option<String>,
438    },
439}
440
441/// Ações do subcomando `secrets` (master-key / AEAD).
442#[derive(Debug, Subcommand)]
443pub enum AcaoSecrets {
444    /// Mostra status da cifragem (sem material sensível).
445    Status {
446        /// Saída em JSON.
447        #[arg(long)]
448        json: bool,
449    },
450    /// Gera e grava master-key (`secrets.key` ou keyring). Nunca imprime a chave.
451    Init {
452        /// Grava no OS keyring em vez de `secrets.key`.
453        #[arg(long)]
454        keyring: bool,
455        /// Sobrescreve chave existente.
456        #[arg(long)]
457        force: bool,
458    },
459    /// Regrava `config.toml` re-cifando secrets com a chave atual.
460    Reencrypt,
461}
462
463/// Faz parsing dos argumentos da CLI.
464#[must_use]
465pub fn parse_args() -> Argumentos {
466    Argumentos::parse()
467}
468
469/// Inicializa `tracing-subscriber`.
470pub fn inicializar_logs(args: &Argumentos) {
471    use tracing_subscriber::{fmt, EnvFilter};
472
473    let filter = if std::env::var("RUST_LOG").is_ok() {
474        EnvFilter::from_default_env()
475    } else if args.verbose {
476        EnvFilter::new("debug")
477    } else if args.quiet {
478        EnvFilter::new("error")
479    } else {
480        EnvFilter::new("info")
481    };
482
483    let _ = fmt()
484        .with_env_filter(filter)
485        .with_writer(std::io::stderr)
486        .with_target(false)
487        .with_ansi(false)
488        .try_init();
489}
490
491/// Gera completions de shell para stdout.
492pub fn gerar_completions(shell: Shell) {
493    use clap::CommandFactory;
494    let mut cmd = Argumentos::command();
495    clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut std::io::stdout());
496}
497
498fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
499    if flag {
500        Ok(Some(crate::vps::ler_segredo_stdin()?))
501    } else {
502        Ok(valor)
503    }
504}
505
506/// Resolve formato de saída: explícito > JSON se não-TTY > Text.
507#[must_use]
508pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
509    explicit.unwrap_or_else(|| {
510        if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
511            FormatoSaida::Json
512        } else {
513            FormatoSaida::Text
514        }
515    })
516}
517
518/// Executa o subcomando solicitado.
519pub async fn executar(args: Argumentos) -> Result<()> {
520    let config_override = args.config_dir.clone();
521    // Alinha `secrets.key` com `--config-dir` / testes isolados.
522    crate::secrets::definir_diretorio_config(config_override.clone());
523    let formato = resolver_formato(args.output_format);
524    let disable_sudo = args.disable_sudo;
525    let replace_host_key = args.replace_host_key;
526
527    match args.comando {
528        Comando::Vps { acao } => {
529            crate::vps::executar_comando_vps(acao, config_override, formato).await
530        }
531        Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
532        Comando::Exec {
533            vps_nome,
534            comando,
535            json,
536            password,
537            password_stdin,
538            key,
539            key_passphrase,
540            key_passphrase_stdin,
541            timeout,
542            description,
543        } => {
544            let password = ler_stdin_se(password_stdin, password)?;
545            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
546            let opts = crate::vps::OpcoesExec {
547                password,
548                key,
549                key_passphrase,
550                timeout,
551                description,
552                replace_host_key,
553                disable_sudo,
554                ..Default::default()
555            };
556            crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
557                .await
558        }
559        Comando::SudoExec {
560            vps_nome,
561            comando,
562            json,
563            password,
564            password_stdin,
565            sudo_password,
566            sudo_password_stdin,
567            key,
568            key_passphrase,
569            key_passphrase_stdin,
570            timeout,
571            description,
572        } => {
573            let password = ler_stdin_se(password_stdin, password)?;
574            let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
575            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
576            let opts = crate::vps::OpcoesExec {
577                password,
578                sudo_password,
579                key,
580                key_passphrase,
581                timeout,
582                description,
583                replace_host_key,
584                disable_sudo,
585                ..Default::default()
586            };
587            crate::vps::executar_sudo_exec(
588                &vps_nome,
589                &comando,
590                config_override,
591                formato,
592                json,
593                opts,
594            )
595            .await
596        }
597        Comando::SuExec {
598            vps_nome,
599            comando,
600            json,
601            password,
602            su_password,
603            su_password_stdin,
604            key,
605            key_passphrase,
606            key_passphrase_stdin,
607            timeout,
608            description,
609        } => {
610            let su_password = ler_stdin_se(su_password_stdin, su_password)?;
611            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
612            let opts = crate::vps::OpcoesExec {
613                password,
614                su_password,
615                key,
616                key_passphrase,
617                timeout,
618                description,
619                replace_host_key,
620                disable_sudo,
621                ..Default::default()
622            };
623            crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
624                .await
625        }
626        Comando::Scp { acao } => {
627            let pwd = match &acao {
628                AcaoScp::Upload { password, .. } | AcaoScp::Download { password, .. } => {
629                    password.clone()
630                }
631            };
632            crate::scp::executar_scp(acao, config_override, pwd).await
633        }
634        Comando::Tunnel {
635            vps_nome,
636            porta_local,
637            host_remoto,
638            porta_remota,
639            timeout_ms,
640            password,
641            key,
642        } => {
643            crate::tunnel::executar_tunnel(
644                &vps_nome,
645                porta_local,
646                &host_remoto,
647                porta_remota,
648                config_override,
649                password,
650                key,
651                timeout_ms,
652                replace_host_key,
653            )
654            .await
655        }
656        Comando::HealthCheck { vps_nome, password } => {
657            crate::vps::executar_health_check(
658                vps_nome.as_deref(),
659                config_override,
660                formato,
661                password,
662            )
663            .await
664        }
665        Comando::Secrets { acao } => {
666            crate::vps::executar_comando_secrets(acao, config_override, formato).await
667        }
668        Comando::Completions { shell } => {
669            gerar_completions(shell);
670            Ok(())
671        }
672    }
673}
674
675#[cfg(test)]
676mod testes {
677    use super::*;
678    use clap::Parser;
679
680    #[test]
681    fn parser_entende_tunnel_com_timeout() {
682        let args = Argumentos::try_parse_from([
683            "ssh-cli",
684            "tunnel",
685            "vps-a",
686            "8080",
687            "127.0.0.1",
688            "5432",
689            "--timeout-ms",
690            "5000",
691        ])
692        .expect("tunnel");
693        match args.comando {
694            Comando::Tunnel {
695                timeout_ms,
696                porta_local,
697                ..
698            } => {
699                assert_eq!(timeout_ms, 5000);
700                assert_eq!(porta_local, 8080);
701            }
702            _ => panic!("esperado tunnel"),
703        }
704    }
705
706    #[test]
707    fn parser_vps_add_key() {
708        let args = Argumentos::try_parse_from([
709            "ssh-cli",
710            "vps",
711            "add",
712            "--name",
713            "x",
714            "--host",
715            "h",
716            "--user",
717            "u",
718            "--key",
719            "/tmp/id_ed25519",
720        ])
721        .expect("add key");
722        match args.comando {
723            Comando::Vps {
724                acao: AcaoVps::Add { key, password, .. },
725            } => {
726                assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
727                assert!(password.is_none());
728            }
729            _ => panic!("esperado add"),
730        }
731    }
732
733    #[test]
734    fn parser_sudo_exec_description() {
735        let args = Argumentos::try_parse_from([
736            "ssh-cli",
737            "sudo-exec",
738            "v",
739            "id",
740            "--description",
741            "who am i",
742        ])
743        .unwrap();
744        match args.comando {
745            Comando::SudoExec { description, .. } => {
746                assert_eq!(description.as_deref(), Some("who am i"));
747            }
748            _ => panic!("sudo-exec"),
749        }
750    }
751
752    #[test]
753    fn parser_su_exec() {
754        let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
755        assert!(matches!(args.comando, Comando::SuExec { .. }));
756    }
757
758    #[test]
759    fn parser_disable_sudo_global() {
760        let args =
761            Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
762        assert!(args.disable_sudo);
763    }
764
765    #[test]
766    fn parser_doctor() {
767        let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
768        match args.comando {
769            Comando::Vps {
770                acao: AcaoVps::Doctor { json },
771            } => assert!(json),
772            _ => panic!("doctor"),
773        }
774    }
775}