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::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::net::TcpListener;
13
14/// Executa o subcomando `tunnel` com timeout obrigatório.
15#[allow(clippy::too_many_arguments)]
16pub async fn executar_tunnel(
17    vps_nome: &str,
18    porta_local: u16,
19    host_remoto: &str,
20    porta_remota: u16,
21    config_override: Option<PathBuf>,
22    password_override: Option<String>,
23    key_override: Option<String>,
24    key_passphrase_override: Option<String>,
25    timeout_ms: u64,
26    replace_host_key: bool,
27    json: bool,
28) -> Result<()> {
29    if timeout_ms == 0 {
30        return Err(ErroSshCli::ArgumentoInvalido(
31            "tunnel exige --timeout-ms > 0 (one-shot limitado)".to_string(),
32        )
33        .into());
34    }
35
36    let mut vps = buscar_por_nome(config_override.clone(), vps_nome)?
37        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
38
39    // GAP-SSH-CLI-005 / M3: paridade com exec/scp via aplicar_overrides (password/key/passphrase).
40    // Timeout do registro VPS não é sobrescrito aqui — o deadline do tunnel é `timeout_ms`.
41    crate::vps::aplicar_overrides(
42        &mut vps,
43        password_override,
44        None,
45        None,
46        None,
47        key_override,
48        key_passphrase_override,
49    );
50
51    let caminho = crate::vps::resolver_caminho_config(config_override)?;
52    let cfg = crate::vps::construir_configuracao(&vps, Some(&caminho), replace_host_key);
53
54    tracing::info!(
55        vps = %vps_nome,
56        porta_local,
57        host_remoto,
58        porta_remota,
59        timeout_ms,
60        "iniciando tunnel SSH com deadline"
61    );
62
63    // GAP-SSH-IO-006: banners só em TTY humano; agentes/pipes não poluem stdout.
64    // GAP-SSH-IO-008: em JSON, zero prosa — evento estruturado após bind.
65    if !json {
66        let banner = format!(
67            "Tunnel SSH: localhost:{} -> {}:{} via {} (timeout {}ms)",
68            porta_local, host_remoto, porta_remota, vps_nome, timeout_ms
69        );
70        tracing::info!("{banner}");
71        output::imprimir_banner_humano(&banner);
72        output::imprimir_banner_humano("Pressione Ctrl+C para encerrar antes do deadline.");
73    }
74
75    // GAP-SSH-TUN-001: deadline cobre connect + loop (não só o accept loop).
76    // GAP-SSH-TUN-002: se o listener local já subiu, o fim por deadline é sucesso one-shot
77    // (não TimeoutSsh/exit 74). Timeout antes do bind (connect lento) permanece erro.
78    let bound = Arc::new(AtomicBool::new(false));
79    let bound_flag = Arc::clone(&bound);
80    let resultado = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
81        let cliente: Box<dyn ClienteSshTrait> =
82            <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
83        executar_tunnel_with_client(
84            vps_nome,
85            porta_local,
86            host_remoto,
87            porta_remota,
88            timeout_ms,
89            json,
90            cliente,
91            Some(bound_flag),
92        )
93        .await
94    })
95    .await;
96
97    match resultado {
98        Ok(inner) => inner,
99        Err(_) if bound.load(Ordering::SeqCst) => {
100            tracing::info!(
101                timeout_ms,
102                "tunnel encerrou por deadline one-shot (sucesso)"
103            );
104            Ok(())
105        }
106        Err(_) => {
107            tracing::warn!(timeout_ms, "tunnel timeout antes do bind local");
108            Err(ErroSshCli::TimeoutSsh(timeout_ms).into())
109        }
110    }
111}
112
113/// Versão testável do loop de tunnel.
114#[allow(clippy::too_many_arguments)]
115pub async fn executar_tunnel_with_client(
116    vps_nome: &str,
117    porta_local: u16,
118    host_remoto: &str,
119    porta_remota: u16,
120    timeout_ms: u64,
121    json: bool,
122    cliente: Box<dyn ClienteSshTrait>,
123    bound_flag: Option<Arc<AtomicBool>>,
124) -> Result<()> {
125    let cliente: std::sync::Arc<dyn ClienteSshTrait> = std::sync::Arc::from(cliente);
126
127    let listener = TcpListener::bind(format!("127.0.0.1:{porta_local}"))
128        .await
129        .map_err(|e| {
130            ErroSshCli::Generico(format!("falha ao abrir porta local {}: {}", porta_local, e))
131        })?;
132
133    if let Some(flag) = bound_flag.as_ref() {
134        flag.store(true, Ordering::SeqCst);
135    }
136
137    tracing::info!(porta = %porta_local, vps = %vps_nome, "listener TCP local iniciado");
138
139    // GAP-SSH-IO-008: agente recebe confirmação estruturada de que o bind local subiu.
140    if json {
141        output::imprimir_tunnel_listening_json(
142            vps_nome,
143            porta_local,
144            host_remoto,
145            porta_remota,
146            timeout_ms,
147        );
148    }
149
150    loop {
151        if crate::signals::cancelado() || crate::signals::terminado() {
152            tracing::info!("tunnel cancelado por sinal");
153            break;
154        }
155
156        tokio::select! {
157            resultado_accept = listener.accept() => {
158                match resultado_accept {
159                    Ok((soquete, addr)) => {
160                        tracing::debug!(endereco = %addr, "nova conexão local");
161                        let host = host_remoto.to_string();
162                        let cliente_c = cliente.clone();
163                        tokio::spawn(async move {
164                            if let Err(e) = encaminhar(soquete, cliente_c, &host, porta_remota).await {
165                                tracing::warn!(erro = %e, "falha no encaminhamento do tunnel");
166                            }
167                        });
168                    }
169                    Err(e) => {
170                        tracing::error!(erro = %e, "accept falhou");
171                        break;
172                    }
173                }
174            }
175            _ = tokio::time::sleep(Duration::from_millis(200)) => {
176                // polling de sinais
177            }
178        }
179    }
180
181    let _ = cliente.desconectar().await;
182    Ok(())
183}
184
185async fn encaminhar(
186    mut local: tokio::net::TcpStream,
187    cliente: std::sync::Arc<dyn ClienteSshTrait>,
188    host_remoto: &str,
189    porta_remota: u16,
190) -> Result<()> {
191    use tokio::io::AsyncWriteExt;
192    let mut canal = cliente
193        .abrir_canal_tunel(host_remoto, porta_remota, "127.0.0.1", 0)
194        .await?;
195    let (mut lr, mut lw) = local.split();
196    let (mut cr, mut cw) = tokio::io::split(&mut *canal);
197    let a = async {
198        let _ = tokio::io::copy(&mut lr, &mut cw).await;
199        let _ = cw.shutdown().await;
200    };
201    let b = async {
202        let _ = tokio::io::copy(&mut cr, &mut lw).await;
203        let _ = lw.shutdown().await;
204    };
205    tokio::join!(a, b);
206    Ok(())
207}
208
209#[cfg(test)]
210mod testes {
211    use super::*;
212    use crate::ssh::cliente::mocks::MockClienteSsh;
213    use crate::ssh::cliente::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
214    use async_trait::async_trait;
215    use std::path::Path;
216    use std::sync::Arc;
217
218    // tunnel tests with mock are limited; ensure timeout_ms 0 fails at API level via unit in cli
219
220    #[test]
221    fn timeout_zero_rejeitado_conceitual() {
222        // validação no executar_tunnel
223        assert_eq!(0_u64, 0);
224    }
225
226    #[tokio::test]
227    async fn tunnel_with_client_encerra_com_cancel() {
228        use crate::ssh::cliente::ClienteSshTrait;
229
230        struct Stub;
231        #[async_trait]
232        impl ClienteSshTrait for Stub {
233            async fn conectar(
234                _cfg: ConfiguracaoConexao,
235            ) -> Result<Box<Self>, crate::erros::ErroSshCli> {
236                Ok(Box::new(Stub))
237            }
238            async fn executar_comando(
239                &mut self,
240                _cmd: &str,
241                _max: usize,
242                _stdin: Option<Vec<u8>>,
243            ) -> Result<SaidaExecucao, crate::erros::ErroSshCli> {
244                unreachable!()
245            }
246            async fn upload(
247                &mut self,
248                _l: &Path,
249                _r: &Path,
250            ) -> Result<TransferenciaResultado, crate::erros::ErroSshCli> {
251                unreachable!()
252            }
253            async fn download(
254                &mut self,
255                _r: &Path,
256                _l: &Path,
257            ) -> Result<TransferenciaResultado, crate::erros::ErroSshCli> {
258                unreachable!()
259            }
260            async fn abrir_canal_tunel(
261                &self,
262                _h: &str,
263                _p: u16,
264                _o: &str,
265                _po: u16,
266            ) -> Result<Box<dyn crate::ssh::cliente::CanalTunel>, crate::erros::ErroSshCli>
267            {
268                Err(crate::erros::ErroSshCli::CanalFalhou("stub".into()))
269            }
270            async fn desconectar(&self) -> Result<(), crate::erros::ErroSshCli> {
271                Ok(())
272            }
273        }
274
275        // bind ephemeral via timeout short path: just ensure desconectar path
276        let stub: Box<dyn ClienteSshTrait> = Box::new(Stub);
277        let _: Arc<dyn ClienteSshTrait> = Arc::from(stub);
278        let _ = MockClienteSsh::new();
279    }
280}