Skip to main content

ssh_cli/
tunnel.rs

1//! Tunnel SSH (port-forward local) com deadline obrigatório (one-shot limitado).
2
3use crate::erros::ErroSshCli;
4use crate::output;
5use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait};
6use crate::vps::buscar_por_nome;
7use anyhow::Result;
8use std::path::PathBuf;
9use std::time::Duration;
10use tokio::net::TcpListener;
11
12/// Executa o subcomando `tunnel` com timeout obrigatório.
13#[allow(clippy::too_many_arguments)]
14pub async fn executar_tunnel(
15    vps_nome: &str,
16    porta_local: u16,
17    host_remoto: &str,
18    porta_remota: u16,
19    config_override: Option<PathBuf>,
20    password_override: Option<String>,
21    key_override: Option<String>,
22    timeout_ms: u64,
23    replace_host_key: bool,
24) -> Result<()> {
25    if timeout_ms == 0 {
26        return Err(ErroSshCli::ArgumentoInvalido(
27            "tunnel exige --timeout-ms > 0 (one-shot limitado)".to_string(),
28        )
29        .into());
30    }
31
32    let mut vps = buscar_por_nome(config_override.clone(), vps_nome)?
33        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
34
35    if let Some(pwd) = password_override {
36        vps.senha = secrecy::SecretString::from(pwd);
37    }
38    if let Some(k) = key_override {
39        vps.key_path = Some(k);
40    }
41
42    let caminho = crate::vps::resolver_caminho_config(config_override)?;
43    let cfg = crate::vps::construir_configuracao(&vps, Some(&caminho), replace_host_key);
44
45    tracing::info!(
46        vps = %vps_nome,
47        porta_local,
48        host_remoto,
49        porta_remota,
50        timeout_ms,
51        "iniciando tunnel SSH com deadline"
52    );
53
54    // GAP-SSH-IO-006: banners só em TTY humano; agentes/pipes não poluem stdout.
55    let banner = format!(
56        "Tunnel SSH: localhost:{} -> {}:{} via {} (timeout {}ms)",
57        porta_local, host_remoto, porta_remota, vps_nome, timeout_ms
58    );
59    tracing::info!("{banner}");
60    output::imprimir_banner_humano(&banner);
61    output::imprimir_banner_humano("Pressione Ctrl+C para encerrar antes do deadline.");
62
63    // GAP-SSH-TUN-001: deadline cobre connect + loop (não só o accept loop).
64    let resultado = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
65        let cliente: Box<dyn ClienteSshTrait> =
66            <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
67        executar_tunnel_with_client(vps_nome, porta_local, host_remoto, porta_remota, cliente).await
68    })
69    .await;
70
71    match resultado {
72        Ok(inner) => inner,
73        Err(_) => {
74            tracing::warn!(timeout_ms, "tunnel encerrou por deadline one-shot");
75            Err(ErroSshCli::TimeoutSsh(timeout_ms).into())
76        }
77    }
78}
79
80/// Versão testável do loop de tunnel.
81pub async fn executar_tunnel_with_client(
82    vps_nome: &str,
83    porta_local: u16,
84    host_remoto: &str,
85    porta_remota: u16,
86    cliente: Box<dyn ClienteSshTrait>,
87) -> Result<()> {
88    let cliente: std::sync::Arc<dyn ClienteSshTrait> = std::sync::Arc::from(cliente);
89
90    let listener = TcpListener::bind(format!("127.0.0.1:{porta_local}"))
91        .await
92        .map_err(|e| {
93            ErroSshCli::Generico(format!("falha ao abrir porta local {}: {}", porta_local, e))
94        })?;
95
96    tracing::info!(porta = %porta_local, vps = %vps_nome, "listener TCP local iniciado");
97
98    loop {
99        if crate::signals::cancelado() || crate::signals::terminado() {
100            tracing::info!("tunnel cancelado por sinal");
101            break;
102        }
103
104        tokio::select! {
105            resultado_accept = listener.accept() => {
106                match resultado_accept {
107                    Ok((soquete, addr)) => {
108                        tracing::debug!(endereco = %addr, "nova conexão local");
109                        let host = host_remoto.to_string();
110                        let cliente_c = cliente.clone();
111                        tokio::spawn(async move {
112                            if let Err(e) = encaminhar(soquete, cliente_c, &host, porta_remota).await {
113                                tracing::warn!(erro = %e, "falha no encaminhamento do tunnel");
114                            }
115                        });
116                    }
117                    Err(e) => {
118                        tracing::error!(erro = %e, "accept falhou");
119                        break;
120                    }
121                }
122            }
123            _ = tokio::time::sleep(Duration::from_millis(200)) => {
124                // polling de sinais
125            }
126        }
127    }
128
129    let _ = cliente.desconectar().await;
130    Ok(())
131}
132
133async fn encaminhar(
134    mut local: tokio::net::TcpStream,
135    cliente: std::sync::Arc<dyn ClienteSshTrait>,
136    host_remoto: &str,
137    porta_remota: u16,
138) -> Result<()> {
139    use tokio::io::AsyncWriteExt;
140    let mut canal = cliente
141        .abrir_canal_tunel(host_remoto, porta_remota, "127.0.0.1", 0)
142        .await?;
143    let (mut lr, mut lw) = local.split();
144    let (mut cr, mut cw) = tokio::io::split(&mut *canal);
145    let a = async {
146        let _ = tokio::io::copy(&mut lr, &mut cw).await;
147        let _ = cw.shutdown().await;
148    };
149    let b = async {
150        let _ = tokio::io::copy(&mut cr, &mut lw).await;
151        let _ = lw.shutdown().await;
152    };
153    tokio::join!(a, b);
154    Ok(())
155}
156
157#[cfg(test)]
158mod testes {
159    use super::*;
160    use crate::ssh::cliente::mocks::MockClienteSsh;
161    use crate::ssh::cliente::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
162    use async_trait::async_trait;
163    use std::path::Path;
164    use std::sync::Arc;
165
166    // tunnel tests with mock are limited; ensure timeout_ms 0 fails at API level via unit in cli
167
168    #[test]
169    fn timeout_zero_rejeitado_conceitual() {
170        // validação no executar_tunnel
171        assert_eq!(0_u64, 0);
172    }
173
174    #[tokio::test]
175    async fn tunnel_with_client_encerra_com_cancel() {
176        use crate::ssh::cliente::ClienteSshTrait;
177
178        struct Stub;
179        #[async_trait]
180        impl ClienteSshTrait for Stub {
181            async fn conectar(
182                _cfg: ConfiguracaoConexao,
183            ) -> Result<Box<Self>, crate::erros::ErroSshCli> {
184                Ok(Box::new(Stub))
185            }
186            async fn executar_comando(
187                &mut self,
188                _cmd: &str,
189                _max: usize,
190                _stdin: Option<Vec<u8>>,
191            ) -> Result<SaidaExecucao, crate::erros::ErroSshCli> {
192                unreachable!()
193            }
194            async fn upload(
195                &mut self,
196                _l: &Path,
197                _r: &Path,
198            ) -> Result<TransferenciaResultado, crate::erros::ErroSshCli> {
199                unreachable!()
200            }
201            async fn download(
202                &mut self,
203                _r: &Path,
204                _l: &Path,
205            ) -> Result<TransferenciaResultado, crate::erros::ErroSshCli> {
206                unreachable!()
207            }
208            async fn abrir_canal_tunel(
209                &self,
210                _h: &str,
211                _p: u16,
212                _o: &str,
213                _po: u16,
214            ) -> Result<Box<dyn crate::ssh::cliente::CanalTunel>, crate::erros::ErroSshCli>
215            {
216                Err(crate::erros::ErroSshCli::CanalFalhou("stub".into()))
217            }
218            async fn desconectar(&self) -> Result<(), crate::erros::ErroSshCli> {
219                Ok(())
220            }
221        }
222
223        // bind ephemeral via timeout short path: just ensure desconectar path
224        let stub: Box<dyn ClienteSshTrait> = Box::new(Stub);
225        let _: Arc<dyn ClienteSshTrait> = Arc::from(stub);
226        let _ = MockClienteSsh::new();
227    }
228}