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 std::path::Path;
177    use tokio::io::{AsyncReadExt, AsyncWriteExt};
178    use tokio::sync::Mutex;
179
180    struct ClienteFakeTunel {
181        canal: Mutex<Option<tokio::io::DuplexStream>>,
182        falhar_ao_abrir: bool,
183    }
184
185    impl ClienteFakeTunel {
186        fn novo(canal: tokio::io::DuplexStream) -> Self {
187            Self {
188                canal: Mutex::new(Some(canal)),
189                falhar_ao_abrir: false,
190            }
191        }
192
193        fn falhando() -> Self {
194            Self {
195                canal: Mutex::new(None),
196                falhar_ao_abrir: true,
197            }
198        }
199    }
200
201    #[async_trait]
202    impl ClienteSshTrait for ClienteFakeTunel {
203        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
204            Err(ErroSshCli::ConexaoFalhou(
205                "não implementado em teste".to_string(),
206            ))
207        }
208
209        async fn executar_comando(
210            &mut self,
211            _cmd: &str,
212            _max_chars: usize,
213        ) -> Result<SaidaExecucao, ErroSshCli> {
214            Err(ErroSshCli::CanalFalhou(
215                "não implementado em teste".to_string(),
216            ))
217        }
218
219        async fn upload(
220            &mut self,
221            _local: &Path,
222            _remote: &Path,
223        ) -> Result<TransferenciaResultado, ErroSshCli> {
224            Err(ErroSshCli::CanalFalhou(
225                "não implementado em teste".to_string(),
226            ))
227        }
228
229        async fn download(
230            &mut self,
231            _remote: &Path,
232            _local: &Path,
233        ) -> Result<TransferenciaResultado, ErroSshCli> {
234            Err(ErroSshCli::CanalFalhou(
235                "não implementado em teste".to_string(),
236            ))
237        }
238
239        async fn abrir_canal_tunel(
240            &self,
241            _host_remoto: &str,
242            _porta_remota: u16,
243            _endereco_origem: &str,
244            _porta_origem: u16,
245        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
246            if self.falhar_ao_abrir {
247                return Err(ErroSshCli::CanalFalhou("falha forçada".to_string()));
248            }
249
250            let mut guard = self.canal.lock().await;
251            let canal = guard
252                .take()
253                .ok_or_else(|| ErroSshCli::CanalFalhou("canal já consumido".to_string()))?;
254            Ok(Box::new(canal))
255        }
256
257        async fn desconectar(&self) -> Result<(), ErroSshCli> {
258            Ok(())
259        }
260    }
261
262    #[test]
263    fn tunnel_modulo_compilou() {
264        // Verifica que o módulo está acessível e compiling
265        let _ = std::file!();
266    }
267
268    #[tokio::test]
269    async fn redireciona_dados_nos_dois_sentidos() {
270        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
271            .await
272            .expect("listener local");
273        let endereco = listener.local_addr().expect("local addr");
274
275        let cliente_lado_local = tokio::net::TcpStream::connect(endereco)
276            .await
277            .expect("conecta no listener");
278        let (soquete_aceito, origem) = listener.accept().await.expect("accept local");
279
280        let (canal_ssh, mut lado_remoto) = tokio::io::duplex(4096);
281        let cliente_fake = ClienteFakeTunel::novo(canal_ssh);
282
283        let tarefa = tokio::spawn(async move {
284            redirecionar_conexao(
285                soquete_aceito,
286                "db-interna",
287                5432,
288                "vps-teste",
289                origem,
290                &cliente_fake,
291            )
292            .await
293        });
294
295        let mut cliente_lado_local = cliente_lado_local;
296        cliente_lado_local
297            .write_all(b"ping")
298            .await
299            .expect("envia ping local");
300
301        let mut buf = [0_u8; 4];
302        lado_remoto
303            .read_exact(&mut buf)
304            .await
305            .expect("le ping no canal remoto");
306        assert_eq!(&buf, b"ping");
307
308        lado_remoto
309            .write_all(b"pong")
310            .await
311            .expect("escreve pong remoto");
312
313        let mut retorno = [0_u8; 4];
314        cliente_lado_local
315            .read_exact(&mut retorno)
316            .await
317            .expect("le pong no cliente local");
318        assert_eq!(&retorno, b"pong");
319
320        cliente_lado_local.shutdown().await.expect("shutdown local");
321        lado_remoto.shutdown().await.expect("shutdown remoto");
322
323        let resultado = tarefa.await.expect("join task");
324        assert!(resultado.is_ok());
325    }
326
327    #[tokio::test]
328    async fn redirecionamento_retorna_erro_quando_falha_abrir_canal_ssh() {
329        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
330            .await
331            .expect("listener local");
332        let endereco = listener.local_addr().expect("local addr");
333
334        let _cliente_lado_local = tokio::net::TcpStream::connect(endereco)
335            .await
336            .expect("conecta no listener");
337        let (soquete_aceito, origem) = listener.accept().await.expect("accept local");
338
339        let cliente_fake = ClienteFakeTunel::falhando();
340
341        let resultado = redirecionar_conexao(
342            soquete_aceito,
343            "db-interna",
344            5432,
345            "vps-teste",
346            origem,
347            &cliente_fake,
348        )
349        .await;
350
351        assert!(resultado.is_err());
352    }
353
354    #[tokio::test]
355    async fn executar_tunnel_with_client_inicia_listener_e_processa_conexao() {
356        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("porta livre");
357        let porta_livre = listener.local_addr().expect("addr").port();
358        drop(listener);
359
360        let (canal_ssh, mut lado_remoto) = tokio::io::duplex(4096);
361        let cliente_fake = Box::new(ClienteFakeTunel::novo(canal_ssh));
362
363        let tarefa_tunel = tokio::spawn(async move {
364            super::executar_tunnel_with_client(
365                "vps-teste",
366                porta_livre,
367                "db-interna",
368                5432,
369                cliente_fake,
370            )
371            .await
372        });
373
374        let mut cliente_local = loop {
375            match tokio::net::TcpStream::connect(("127.0.0.1", porta_livre)).await {
376                Ok(stream) => break stream,
377                Err(_) => tokio::time::sleep(std::time::Duration::from_millis(10)).await,
378            }
379        };
380
381        cliente_local
382            .write_all(b"ok")
383            .await
384            .expect("envia bytes locais");
385
386        let mut recebido = [0_u8; 2];
387        lado_remoto
388            .read_exact(&mut recebido)
389            .await
390            .expect("lê bytes no canal remoto");
391        assert_eq!(&recebido, b"ok");
392
393        tarefa_tunel.abort();
394    }
395}