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, ConfiguracaoConexao};
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) -> Result<()> {
28    let vps = buscar_por_nome(config_override.clone(), vps_nome)?
29        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
30
31    let cfg = ConfiguracaoConexao {
32        host: vps.host.clone(),
33        porta: vps.porta,
34        usuario: vps.usuario.clone(),
35        senha: vps.senha.clone(),
36        timeout_ms: vps.timeout_ms,
37    };
38
39    tracing::info!(
40        vps = %vps_nome,
41        porta_local,
42        host_remoto,
43        porta_remota,
44        "iniciando tunnel SSH"
45    );
46
47    output::escrever_linha(&format!(
48        "Tunnel SSH: localhost:{} -> {}:{} via {}",
49        porta_local, host_remoto, porta_remota, vps_nome
50    ))?;
51    output::escrever_linha("Pressione Ctrl+C para encerrar.")?;
52
53    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
54    executar_tunnel_with_client(vps_nome, porta_local, host_remoto, porta_remota, cliente).await
55}
56
57/// Versão testável de executar_tunnel que aceita o cliente como parâmetro.
58pub async fn executar_tunnel_with_client(
59    vps_nome: &str,
60    porta_local: u16,
61    host_remoto: &str,
62    porta_remota: u16,
63    cliente: Box<dyn ClienteSshTrait>,
64) -> Result<()> {
65    let cliente = std::sync::Arc::from(cliente);
66
67    let listener = TcpListener::bind(format!("127.0.0.1:{porta_local}"))
68        .await
69        .map_err(|e| {
70            ErroSshCli::Generico(format!("falha ao abrir porta local {}: {}", porta_local, e))
71        })?;
72
73    tracing::info!(porta = %porta_local, "listener TCP local iniciado");
74
75    loop {
76        tokio::select! {
77            resultado_accept = listener.accept() => {
78                match resultado_accept {
79                    Ok((soquete, addr)) => {
80                        tracing::debug!(endereco = %addr, "nova conexão local");
81                        let host = host_remoto.to_string();
82                        let porta = porta_remota;
83                        let vps = vps_nome.to_string();
84                        let cliente = std::sync::Arc::clone(&cliente);
85
86                        tokio::spawn(async move {
87                            if let Err(e) =
88                                redirecionar_conexao(soquete, &host, porta, &vps, addr, &*cliente).await
89                            {
90                                tracing::error!(erro = %e, "erro no redirecionamento");
91                            }
92                        });
93                    }
94                    Err(e) => {
95                        tracing::error!(erro = %e, "erro ao aceitar conexão local");
96                        break;
97                    }
98                }
99            }
100            _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {
101                if crate::signals::cancelado() {
102                    tracing::info!("tunnel encerrado por sinal de cancelamento");
103                    break;
104                }
105            }
106        }
107    }
108
109    if let Err(e) = cliente.desconectar().await {
110        tracing::warn!(erro = %e, "erro ao desconectar cliente SSH");
111    }
112
113    output::escrever_linha("Tunnel encerrado.")?;
114    Ok(())
115}
116
117async fn redirecionar_conexao(
118    mut soquete: tokio::net::TcpStream,
119    host_remoto: &str,
120    porta_remota: u16,
121    vps_nome: &str,
122    origem: std::net::SocketAddr,
123    cliente: &dyn ClienteSshTrait,
124) -> Result<()> {
125    let mut canal_tunel = cliente
126        .abrir_canal_tunel(
127            host_remoto,
128            porta_remota,
129            &origem.ip().to_string(),
130            origem.port(),
131        )
132        .await
133        .map_err(|e| {
134            ErroSshCli::Generico(format!(
135                "falha ao abrir tunnel SSH para {}:{}: {}",
136                host_remoto, porta_remota, e
137            ))
138        })?;
139
140    tracing::debug!(host = %host_remoto, porta = %porta_remota, "redirecionando conexão");
141
142    tracing::debug!(
143        vps = %vps_nome,
144        host = %host_remoto,
145        porta = %porta_remota,
146        origem = %origem,
147        "redirecionando conexão local para remoto via SSH"
148    );
149
150    let (bytes_local_remoto, bytes_remoto_local) =
151        tokio::io::copy_bidirectional(&mut soquete, &mut canal_tunel)
152            .await
153            .map_err(|e| {
154                ErroSshCli::Generico(format!(
155                    "falha ao trafegar dados no tunnel {}:{}: {}",
156                    host_remoto, porta_remota, e
157                ))
158            })?;
159
160    tracing::debug!(
161        bytes_local_remoto,
162        bytes_remoto_local,
163        "sessão de tunnel encerrada"
164    );
165
166    Ok(())
167}
168
169#[cfg(test)]
170mod testes {
171    use super::redirecionar_conexao;
172    use crate::erros::ErroSshCli;
173    use crate::ssh::cliente::{
174        CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
175    };
176    use async_trait::async_trait;
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}