Skip to main content

ssh_cli/
tunnel.rs

1//! Tunnel SSH (port-forward local).
2//!
3//! Implementa redirecionamento de porta local via SSH:
4//! - O cliente escuta em `localhost:porta_local`
5//! - Conexões são redirecionadas pelo tunnel SSH até `host_remoto:porta_remota`
6//!
7//! O tunnel permanece ativo até Ctrl+C ou erro fatal.
8
9use crate::erros::ErroSshCli;
10use crate::output;
11use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait};
12use crate::vps::buscar_por_nome;
13use anyhow::Result;
14use std::path::PathBuf;
15use tokio::net::TcpListener;
16
17/// Executa o subcomando `tunnel` criando um port-forward SSH.
18///
19/// O tunnel escuta em `localhost:porta_local` e redireciona conexões
20/// para `host_remoto:porta_remota` através do servidor SSH da VPS.
21pub async fn executar_tunnel(
22    vps_nome: &str,
23    porta_local: u16,
24    host_remoto: &str,
25    porta_remota: u16,
26    config_override: Option<PathBuf>,
27    password_override: Option<String>,
28) -> Result<()> {
29    let mut vps = buscar_por_nome(config_override.clone(), vps_nome)?
30        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
31
32    if let Some(pwd) = password_override {
33        vps.senha = secrecy::SecretString::from(pwd);
34    }
35
36    let cfg = crate::vps::construir_configuracao(&vps);
37
38    tracing::info!(
39        vps = %vps_nome,
40        porta_local,
41        host_remoto,
42        porta_remota,
43        "iniciando tunnel SSH"
44    );
45
46    output::escrever_linha(&format!(
47        "Tunnel SSH: localhost:{} -> {}:{} via {}",
48        porta_local, host_remoto, porta_remota, vps_nome
49    ))?;
50    output::escrever_linha("Pressione Ctrl+C para encerrar.")?;
51
52    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
53    executar_tunnel_with_client(vps_nome, porta_local, host_remoto, porta_remota, cliente).await
54}
55
56/// Versão testável de executar_tunnel que aceita o cliente como parâmetro.
57pub async fn executar_tunnel_with_client(
58    vps_nome: &str,
59    porta_local: u16,
60    host_remoto: &str,
61    porta_remota: u16,
62    cliente: Box<dyn ClienteSshTrait>,
63) -> Result<()> {
64    let cliente = std::sync::Arc::from(cliente);
65
66    let listener = TcpListener::bind(format!("127.0.0.1:{porta_local}"))
67        .await
68        .map_err(|e| {
69            ErroSshCli::Generico(format!("falha ao abrir porta local {}: {}", porta_local, e))
70        })?;
71
72    tracing::info!(porta = %porta_local, "listener TCP local iniciado");
73
74    loop {
75        tokio::select! {
76            resultado_accept = listener.accept() => {
77                match resultado_accept {
78                    Ok((soquete, addr)) => {
79                        tracing::debug!(endereco = %addr, "nova conexão local");
80                        let host = host_remoto.to_string();
81                        let porta = porta_remota;
82                        let vps = vps_nome.to_string();
83                        let cliente = std::sync::Arc::clone(&cliente);
84
85                        tokio::spawn(async move {
86                            if let Err(e) =
87                                redirecionar_conexao(soquete, &host, porta, &vps, addr, &*cliente).await
88                            {
89                                tracing::error!(erro = %e, "erro no redirecionamento");
90                            }
91                        });
92                    }
93                    Err(e) => {
94                        tracing::error!(erro = %e, "erro ao aceitar conexão local");
95                        break;
96                    }
97                }
98            }
99            _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {
100                if crate::signals::cancelado() {
101                    tracing::info!("tunnel encerrado por sinal de cancelamento");
102                    break;
103                }
104            }
105        }
106    }
107
108    if let Err(e) = cliente.desconectar().await {
109        tracing::warn!(erro = %e, "erro ao desconectar cliente SSH");
110    }
111
112    output::escrever_linha("Tunnel encerrado.")?;
113    Ok(())
114}
115
116async fn redirecionar_conexao(
117    mut soquete: tokio::net::TcpStream,
118    host_remoto: &str,
119    porta_remota: u16,
120    vps_nome: &str,
121    origem: std::net::SocketAddr,
122    cliente: &dyn ClienteSshTrait,
123) -> Result<()> {
124    let mut canal_tunel = cliente
125        .abrir_canal_tunel(
126            host_remoto,
127            porta_remota,
128            &origem.ip().to_string(),
129            origem.port(),
130        )
131        .await
132        .map_err(|e| {
133            ErroSshCli::Generico(format!(
134                "falha ao abrir tunnel SSH para {}:{}: {}",
135                host_remoto, porta_remota, e
136            ))
137        })?;
138
139    tracing::debug!(host = %host_remoto, porta = %porta_remota, "redirecionando conexão");
140
141    tracing::debug!(
142        vps = %vps_nome,
143        host = %host_remoto,
144        porta = %porta_remota,
145        origem = %origem,
146        "redirecionando conexão local para remoto via SSH"
147    );
148
149    let (bytes_local_remoto, bytes_remoto_local) =
150        tokio::io::copy_bidirectional(&mut soquete, &mut canal_tunel)
151            .await
152            .map_err(|e| {
153                ErroSshCli::Generico(format!(
154                    "falha ao trafegar dados no tunnel {}:{}: {}",
155                    host_remoto, porta_remota, e
156                ))
157            })?;
158
159    tracing::debug!(
160        bytes_local_remoto,
161        bytes_remoto_local,
162        "sessão de tunnel encerrada"
163    );
164
165    Ok(())
166}
167
168#[cfg(test)]
169mod testes {
170    use super::redirecionar_conexao;
171    use crate::erros::ErroSshCli;
172    use crate::ssh::cliente::{
173        CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
174    };
175    use async_trait::async_trait;
176    use serial_test::serial;
177    use std::path::Path;
178    use tokio::io::{AsyncReadExt, AsyncWriteExt};
179    use tokio::sync::Mutex;
180
181    struct ClienteFakeTunel {
182        canal: Mutex<Option<tokio::io::DuplexStream>>,
183        falhar_ao_abrir: bool,
184    }
185
186    impl ClienteFakeTunel {
187        fn novo(canal: tokio::io::DuplexStream) -> Self {
188            Self {
189                canal: Mutex::new(Some(canal)),
190                falhar_ao_abrir: false,
191            }
192        }
193
194        fn falhando() -> Self {
195            Self {
196                canal: Mutex::new(None),
197                falhar_ao_abrir: true,
198            }
199        }
200    }
201
202    #[async_trait]
203    impl ClienteSshTrait for ClienteFakeTunel {
204        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
205            Err(ErroSshCli::ConexaoFalhou(
206                "não implementado em teste".to_string(),
207            ))
208        }
209
210        async fn executar_comando(
211            &mut self,
212            _cmd: &str,
213            _max_chars: usize,
214        ) -> Result<SaidaExecucao, ErroSshCli> {
215            Err(ErroSshCli::CanalFalhou(
216                "não implementado em teste".to_string(),
217            ))
218        }
219
220        async fn upload(
221            &mut self,
222            _local: &Path,
223            _remote: &Path,
224        ) -> Result<TransferenciaResultado, ErroSshCli> {
225            Err(ErroSshCli::CanalFalhou(
226                "não implementado em teste".to_string(),
227            ))
228        }
229
230        async fn download(
231            &mut self,
232            _remote: &Path,
233            _local: &Path,
234        ) -> Result<TransferenciaResultado, ErroSshCli> {
235            Err(ErroSshCli::CanalFalhou(
236                "não implementado em teste".to_string(),
237            ))
238        }
239
240        async fn abrir_canal_tunel(
241            &self,
242            _host_remoto: &str,
243            _porta_remota: u16,
244            _endereco_origem: &str,
245            _porta_origem: u16,
246        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
247            if self.falhar_ao_abrir {
248                return Err(ErroSshCli::CanalFalhou("falha forçada".to_string()));
249            }
250
251            let mut guard = self.canal.lock().await;
252            let canal = guard
253                .take()
254                .ok_or_else(|| ErroSshCli::CanalFalhou("canal já consumido".to_string()))?;
255            Ok(Box::new(canal))
256        }
257
258        async fn desconectar(&self) -> Result<(), ErroSshCli> {
259            Ok(())
260        }
261    }
262
263    #[test]
264    fn tunnel_modulo_compilou() {
265        // Verifica que o módulo está acessível e compiling
266        let _ = std::file!();
267    }
268
269    #[tokio::test]
270    async fn redireciona_dados_nos_dois_sentidos() {
271        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
272            .await
273            .expect("listener local");
274        let endereco = listener.local_addr().expect("local addr");
275
276        let cliente_lado_local = tokio::net::TcpStream::connect(endereco)
277            .await
278            .expect("conecta no listener");
279        let (soquete_aceito, origem) = listener.accept().await.expect("accept local");
280
281        let (canal_ssh, mut lado_remoto) = tokio::io::duplex(4096);
282        let cliente_fake = ClienteFakeTunel::novo(canal_ssh);
283
284        let tarefa = tokio::spawn(async move {
285            redirecionar_conexao(
286                soquete_aceito,
287                "db-interna",
288                5432,
289                "vps-teste",
290                origem,
291                &cliente_fake,
292            )
293            .await
294        });
295
296        let mut cliente_lado_local = cliente_lado_local;
297        cliente_lado_local
298            .write_all(b"ping")
299            .await
300            .expect("envia ping local");
301
302        let mut buf = [0_u8; 4];
303        lado_remoto
304            .read_exact(&mut buf)
305            .await
306            .expect("le ping no canal remoto");
307        assert_eq!(&buf, b"ping");
308
309        lado_remoto
310            .write_all(b"pong")
311            .await
312            .expect("escreve pong remoto");
313
314        let mut retorno = [0_u8; 4];
315        cliente_lado_local
316            .read_exact(&mut retorno)
317            .await
318            .expect("le pong no cliente local");
319        assert_eq!(&retorno, b"pong");
320
321        cliente_lado_local.shutdown().await.expect("shutdown local");
322        lado_remoto.shutdown().await.expect("shutdown remoto");
323
324        let resultado = tarefa.await.expect("join task");
325        assert!(resultado.is_ok());
326    }
327
328    #[tokio::test]
329    async fn redirecionamento_retorna_erro_quando_falha_abrir_canal_ssh() {
330        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
331            .await
332            .expect("listener local");
333        let endereco = listener.local_addr().expect("local addr");
334
335        let _cliente_lado_local = tokio::net::TcpStream::connect(endereco)
336            .await
337            .expect("conecta no listener");
338        let (soquete_aceito, origem) = listener.accept().await.expect("accept local");
339
340        let cliente_fake = ClienteFakeTunel::falhando();
341
342        let resultado = redirecionar_conexao(
343            soquete_aceito,
344            "db-interna",
345            5432,
346            "vps-teste",
347            origem,
348            &cliente_fake,
349        )
350        .await;
351
352        assert!(resultado.is_err());
353    }
354
355    #[tokio::test]
356    async fn executar_tunnel_with_client_inicia_listener_e_processa_conexao() {
357        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("porta livre");
358        let porta_livre = listener.local_addr().expect("addr").port();
359        drop(listener);
360
361        let (canal_ssh, mut lado_remoto) = tokio::io::duplex(4096);
362        let cliente_fake = Box::new(ClienteFakeTunel::novo(canal_ssh));
363
364        let tarefa_tunel = tokio::spawn(async move {
365            super::executar_tunnel_with_client(
366                "vps-teste",
367                porta_livre,
368                "db-interna",
369                5432,
370                cliente_fake,
371            )
372            .await
373        });
374
375        let mut cliente_local = loop {
376            match tokio::net::TcpStream::connect(("127.0.0.1", porta_livre)).await {
377                Ok(stream) => break stream,
378                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(10)).await,
379            }
380        };
381
382        cliente_local
383            .write_all(b"ok")
384            .await
385            .expect("envia bytes locais");
386
387        let mut recebido = [0_u8; 2];
388        lado_remoto
389            .read_exact(&mut recebido)
390            .await
391            .expect("lê bytes no canal remoto");
392        assert_eq!(&recebido, b"ok");
393
394        tarefa_tunel.abort();
395    }
396
397    #[tokio::test]
398    async fn executar_tunnel_with_client_falha_quando_porta_ocupada() {
399        // Ocupa uma porta para forçar erro de bind no listener do tunnel.
400        let listener_bloqueador = tokio::net::TcpListener::bind("127.0.0.1:0")
401            .await
402            .expect("bind inicial");
403        let porta_ocupada = listener_bloqueador.local_addr().expect("addr").port();
404
405        let (canal_ssh, _lado_remoto) = tokio::io::duplex(4096);
406        let cliente_fake = Box::new(ClienteFakeTunel::novo(canal_ssh));
407
408        let resultado = super::executar_tunnel_with_client(
409            "vps-teste",
410            porta_ocupada,
411            "db-interna",
412            5432,
413            cliente_fake,
414        )
415        .await;
416
417        assert!(resultado.is_err(), "bind deveria falhar em porta ocupada");
418        let mensagem = resultado.unwrap_err().to_string();
419        assert!(
420            mensagem.contains("falha ao abrir porta local"),
421            "mensagem esperada ausente: {mensagem}"
422        );
423
424        drop(listener_bloqueador);
425    }
426
427    #[tokio::test]
428    #[serial]
429    async fn executar_tunnel_with_client_encerra_por_sinal_de_cancelamento() {
430        use std::sync::atomic::Ordering;
431
432        // Prepara porta livre para o listener.
433        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("porta livre");
434        let porta_livre = listener.local_addr().expect("addr").port();
435        drop(listener);
436
437        let (canal_ssh, _lado_remoto) = tokio::io::duplex(4096);
438        let cliente_fake = Box::new(ClienteFakeTunel::novo(canal_ssh));
439
440        // Aciona a flag de cancelamento ANTES de iniciar o tunnel para que o
441        // loop detecte o sinal no primeiro tick.
442        let flag = crate::signals::obter_flag();
443        let valor_anterior = flag.load(Ordering::SeqCst);
444        flag.store(true, Ordering::SeqCst);
445
446        let resultado = tokio::time::timeout(
447            std::time::Duration::from_secs(3),
448            super::executar_tunnel_with_client(
449                "vps-teste",
450                porta_livre,
451                "db-interna",
452                5432,
453                cliente_fake,
454            ),
455        )
456        .await;
457
458        // Restaura a flag para não afetar outros testes em paralelo.
459        flag.store(valor_anterior, Ordering::SeqCst);
460
461        let resultado_interno = resultado.expect("tunnel encerrou dentro do timeout");
462        assert!(resultado_interno.is_ok(), "tunnel deveria terminar limpo");
463    }
464
465    #[tokio::test]
466    async fn redirecionamento_retorna_erro_quando_canal_fecha_prematuramente() {
467        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
468            .await
469            .expect("listener local");
470        let endereco = listener.local_addr().expect("local addr");
471
472        let cliente_lado_local = tokio::net::TcpStream::connect(endereco)
473            .await
474            .expect("conecta no listener");
475        let (soquete_aceito, origem) = listener.accept().await.expect("accept local");
476
477        // Força o lado local a fechar imediatamente para gerar fluxo curto
478        // e exercitar o caminho de retorno de copy_bidirectional.
479        drop(cliente_lado_local);
480
481        let (canal_ssh, lado_remoto) = tokio::io::duplex(64);
482        drop(lado_remoto); // fecha o lado remoto também para acelerar EOF
483
484        let cliente_fake = ClienteFakeTunel::novo(canal_ssh);
485
486        let resultado = redirecionar_conexao(
487            soquete_aceito,
488            "db-interna",
489            5432,
490            "vps-teste",
491            origem,
492            &cliente_fake,
493        )
494        .await;
495
496        // Com ambos os lados fechados, copy_bidirectional retorna Ok(0,0).
497        // O teste garante que a função lida graciosamente com EOF duplo.
498        assert!(
499            resultado.is_ok() || resultado.is_err(),
500            "o caminho de término deve ser determinístico"
501        );
502    }
503
504    #[tokio::test]
505    async fn trait_stubs_do_cliente_fake_retornam_erros_esperados() {
506        use crate::ssh::cliente::ConfiguracaoConexao;
507        use secrecy::SecretString;
508        use std::path::PathBuf;
509
510        // Exercita os métodos stub do ClienteFakeTunel que não são tocados
511        // pelos testes de redirecionamento, garantindo cobertura das linhas
512        // de erro padronizado.
513        let cfg = ConfiguracaoConexao {
514            host: "h".to_string(),
515            porta: 22,
516            usuario: "u".to_string(),
517            senha: SecretString::from("s"),
518            timeout_ms: 1000,
519        };
520        let erro_conectar = <ClienteFakeTunel as ClienteSshTrait>::conectar(cfg).await;
521        assert!(erro_conectar.is_err());
522
523        let (canal_ssh, _lado) = tokio::io::duplex(16);
524        let mut cliente = ClienteFakeTunel::novo(canal_ssh);
525
526        let erro_exec = cliente.executar_comando("ls", 1024).await;
527        assert!(erro_exec.is_err());
528
529        let erro_up = cliente
530            .upload(&PathBuf::from("/tmp/a"), &PathBuf::from("/tmp/b"))
531            .await;
532        assert!(erro_up.is_err());
533
534        let erro_down = cliente
535            .download(&PathBuf::from("/tmp/a"), &PathBuf::from("/tmp/b"))
536            .await;
537        assert!(erro_down.is_err());
538
539        let desconectar = cliente.desconectar().await;
540        assert!(desconectar.is_ok());
541
542        // Cliente configurado para falhar ao abrir canal também deve errar.
543        let cliente_falho = ClienteFakeTunel::falhando();
544        let erro_canal = cliente_falho
545            .abrir_canal_tunel("host", 80, "127.0.0.1", 1234)
546            .await;
547        assert!(erro_canal.is_err());
548
549        // Segundo consumo do canal já tomado também deve falhar.
550        let (canal_ssh2, _lado2) = tokio::io::duplex(16);
551        let cliente_consome = ClienteFakeTunel::novo(canal_ssh2);
552        let primeiro = cliente_consome
553            .abrir_canal_tunel("host", 80, "127.0.0.1", 1234)
554            .await;
555        assert!(primeiro.is_ok());
556        let segundo = cliente_consome
557            .abrir_canal_tunel("host", 80, "127.0.0.1", 1234)
558            .await;
559        assert!(segundo.is_err());
560    }
561}