Skip to main content

ssh_cli/
tunnel.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! SSH tunnel (local port-forward) with mandatory deadline (bounded one-shot).
5
6use crate::errors::SshCliError;
7use crate::output;
8use crate::ssh::client::{SshClient, SshClientTrait};
9use crate::vps::find_by_name;
10use anyhow::Result;
11use std::path::PathBuf;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::net::TcpListener;
16
17/// Runs the `tunnel` subcommand with a mandatory timeout.
18#[allow(clippy::too_many_arguments)]
19pub async fn run_tunnel(
20    vps_name: &str,
21    local_port: u16,
22    remote_host: &str,
23    remote_port: u16,
24    config_override: Option<PathBuf>,
25    password_override: Option<secrecy::SecretString>,
26    key_override: Option<String>,
27    key_passphrase_override: Option<secrecy::SecretString>,
28    timeout_ms: u64,
29    replace_host_key: bool,
30    json: bool,
31    bind_addr: &str,
32) -> Result<()> {
33    if timeout_ms == 0 {
34        return Err(SshCliError::InvalidArgument(
35            "tunnel requires --timeout-ms > 0 (bounded one-shot)".to_string(),
36        )
37        .into());
38    }
39
40    let mut vps = find_by_name(config_override.as_deref(), vps_name)?
41        .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
42
43    // GAP-SSH-CLI-005 / M3: parity with exec/scp via apply_overrides (password/key/passphrase).
44    // VPS record timeout is not overridden here — the tunnel deadline is `timeout_ms`.
45    crate::vps::apply_overrides(
46        &mut vps,
47        password_override,
48        None,
49        None,
50        None,
51        key_override,
52        key_passphrase_override,
53        false,
54        None,
55    );
56
57    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
58    let cfg = crate::vps::build_connection_config(&vps, Some(&path), replace_host_key);
59
60    tracing::info!(
61        vps = %vps_name,
62        local_port,
63        remote_host,
64        remote_port,
65        timeout_ms,
66        "starting SSH tunnel with deadline"
67    );
68
69    // GAP-SSH-IO-006: banners only on human TTY; agents/pipes do not pollute stdout.
70    // GAP-SSH-IO-008: in JSON, zero prose — structured event after bind.
71    // Banner with effective port is post-bind (TUN-003: port 0 is ephemeral).
72    if !json {
73        output::print_human_banner(
74            "Press Ctrl+C to stop the tunnel before the deadline.",
75        );
76    }
77
78    // GAP-SSH-TUN-001: deadline covers connect + loop (not only the accept loop).
79    // GAP-SSH-TUN-002: if the local listener is already up, deadline end is one-shot success
80    // (not SshTimeout/exit 74). Timeout before bind (slow connect) remains an error.
81    // Interior mutability: Arc<AtomicBool> shares the "listener up" bit between
82    // the timeout wrapper and the accept loop (Release store / Acquire load).
83    // Not RefCell/Mutex — a single independent flag is enough.
84    let bound = Arc::new(AtomicBool::new(false));
85    let bound_flag = Arc::clone(&bound);
86    let result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
87        let client: Box<dyn SshClientTrait> =
88            <SshClient as SshClientTrait>::connect(cfg).await?;
89        run_tunnel_with_client(
90            vps_name,
91            local_port,
92            remote_host,
93            remote_port,
94            timeout_ms,
95            json,
96            client,
97            Some(bound_flag),
98            bind_addr,
99        )
100        .await
101    })
102    .await;
103
104    match result {
105        Ok(inner) => inner,
106        Err(_) if bound.load(Ordering::Acquire) => {
107            tracing::info!(
108                timeout_ms,
109                "tunnel ended by one-shot deadline (success)"
110            );
111            Ok(())
112        }
113        Err(_) => {
114            tracing::warn!(timeout_ms, "tunnel timeout before local bind");
115            Err(SshCliError::SshTimeout(timeout_ms).into())
116        }
117    }
118}
119
120/// Testable tunnel loop.
121#[allow(clippy::too_many_arguments)]
122pub async fn run_tunnel_with_client(
123    vps_name: &str,
124    local_port: u16,
125    remote_host: &str,
126    remote_port: u16,
127    timeout_ms: u64,
128    json: bool,
129    client: Box<dyn SshClientTrait>,
130    bound_flag: Option<Arc<AtomicBool>>,
131    bind_addr: &str,
132) -> Result<()> {
133    let client: std::sync::Arc<dyn SshClientTrait> = std::sync::Arc::from(client);
134
135    let bind_target = format!("{bind_addr}:{local_port}");
136    let listener = TcpListener::bind(&bind_target).await.map_err(|e| {
137        SshCliError::Config(format!(
138            "failed to bind local address {bind_target}: {e}"
139        ))
140    })?;
141
142    // GAP-SSH-TUN-003: port 0 (ephemeral) must report the OS-assigned real port.
143    // Agents use `local_port` from the `tunnel_listening` event to connect.
144    let effective_port = listener
145        .local_addr()
146        .map(|a| a.port())
147        .unwrap_or(local_port);
148
149    if let Some(flag) = bound_flag.as_ref() {
150        // Release: publish "listener up" to the deadline task (Acquire load).
151        flag.store(true, Ordering::Release);
152    }
153
154    tracing::info!(port = %effective_port, requested = %local_port, vps = %vps_name, "local TCP listener started");
155
156    // GAP-SSH-IO-008: agent receives structured confirmation that local bind is up.
157    // GAP-SSH-TUN-003: always report `effective_port` (not the requested port when 0).
158    if json {
159        output::print_tunnel_listening_json(
160            vps_name,
161            effective_port,
162            remote_host,
163            remote_port,
164            timeout_ms,
165        )?;
166    } else {
167        let banner = format!(
168            "Tunnel SSH: localhost:{} -> {}:{} via {} (timeout {}ms)",
169            effective_port, remote_host, remote_port, vps_name, timeout_ms
170        );
171        tracing::info!("{banner}");
172        output::print_human_banner(&banner);
173    }
174
175    // Track forwards so shutdown can drain/abort instead of detaching `tokio::spawn`.
176    // Admission gate: Semaphore (Rules Rust — never unbounded spawn on accept).
177    // Workload: I/O-bound bidirectional copy; saturates FDs + SSH channels.
178    let mut forwards = tokio::task::JoinSet::new();
179    let forward_limit = crate::concurrency::effective_limit();
180    let forward_sem = crate::concurrency::semaphore(forward_limit);
181    tracing::debug!(
182        max_concurrency = forward_limit,
183        "tunnel forward admission gate ready"
184    );
185
186    loop {
187        if crate::signals::should_stop() {
188            tracing::info!(
189                force = crate::signals::is_force_exit(),
190                "tunnel cancelled by signal"
191            );
192            break;
193        }
194
195        tokio::select! {
196            accept_result = listener.accept() => {
197                match accept_result {
198                    Ok((socket, addr)) => {
199                        tracing::debug!(address = %addr, "new local connection");
200                        // G-NET: low-latency local forward (Nagle off on accepted peer).
201                        if let Err(e) = socket.set_nodelay(true) {
202                            tracing::debug!(err = %e, %addr, "tunnel set_nodelay failed");
203                        }
204                        let host = remote_host.to_string();
205                        // Explicit Arc::clone: refcount only (not deep clone of the client).
206                        let client_c = Arc::clone(&client);
207                        // Block new accepts from over-subscribing: acquire before spawn,
208                        // interleaved with join_next via try_acquire + wait path below.
209                        let permit = match forward_sem.clone().try_acquire_owned() {
210                            Ok(p) => p,
211                            Err(_) => {
212                                // At capacity: wait for a permit or a completed forward.
213                                tokio::select! {
214                                    p = crate::concurrency::acquire_owned(&forward_sem) => p,
215                                    Some(joined) = forwards.join_next() => {
216                                        if let Err(e) = joined {
217                                            tracing::debug!(err = %e, "tunnel forward task ended with join error");
218                                        }
219                                        crate::concurrency::acquire_owned(&forward_sem).await
220                                    }
221                                }
222                            }
223                        };
224                        forwards.spawn(async move {
225                            let _permit = permit; // RAII release on task end
226                            if let Err(e) = forward(socket, client_c, &host, remote_port).await {
227                                tracing::warn!(err = %e, "tunnel forwarding failed");
228                            }
229                        });
230                    }
231                    Err(e) => {
232                        // G-NET: do not tear down the accept loop on transient errors.
233                        if matches!(
234                            e.kind(),
235                            std::io::ErrorKind::Interrupted
236                                | std::io::ErrorKind::WouldBlock
237                                | std::io::ErrorKind::ConnectionAborted
238                                | std::io::ErrorKind::ConnectionReset
239                        ) {
240                            tracing::debug!(err = %e, "transient accept error; continuing");
241                            continue;
242                        }
243                        tracing::error!(err = %e, "accept failed (fatal)");
244                        break;
245                    }
246                }
247            }
248            // Reap completed forwards so JoinSet does not grow unbounded.
249            Some(joined) = forwards.join_next() => {
250                if let Err(e) = joined {
251                    tracing::debug!(err = %e, "tunnel forward task ended with join error");
252                }
253            }
254            _ = tokio::time::sleep(Duration::from_millis(
255                crate::constants::TUNNEL_SIGNAL_POLL_INTERVAL_MS,
256            )) => {
257                // signal polling interval
258            }
259        }
260    }
261
262    // Stop accepting new local connections, then drain or abort active forwards.
263    drop(listener);
264    if crate::signals::is_force_exit() {
265        tracing::info!("force-exit: aborting tunnel forwards");
266        forwards.abort_all();
267    }
268    // Bounded drain: cooperative cancel gets a short grace; force already aborted.
269    let drain = tokio::time::timeout(
270        Duration::from_secs(crate::constants::TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS),
271        async {
272            while forwards.join_next().await.is_some() {}
273        },
274    )
275    .await;
276    if drain.is_err() {
277        tracing::warn!("tunnel forward drain timed out; aborting remainder");
278        forwards.abort_all();
279        while forwards.join_next().await.is_some() {}
280    }
281
282    let _ = client.disconnect().await;
283    Ok(())
284}
285
286async fn forward(
287    mut local: tokio::net::TcpStream,
288    client: std::sync::Arc<dyn SshClientTrait>,
289    remote_host: &str,
290    remote_port: u16,
291) -> Result<()> {
292    use tokio::io::AsyncWriteExt;
293    let mut canal = client
294        .open_tunnel_channel(
295            remote_host,
296            remote_port,
297            crate::constants::TUNNEL_CHANNEL_ORIGIN_ADDR,
298            crate::constants::TUNNEL_CHANNEL_ORIGIN_PORT,
299        )
300        .await?;
301    let (mut lr, mut lw) = local.split();
302    let (mut cr, mut cw) = tokio::io::split(&mut *canal);
303    let a = async {
304        let _ = tokio::io::copy(&mut lr, &mut cw).await;
305        let _ = cw.shutdown().await;
306    };
307    let b = async {
308        let _ = tokio::io::copy(&mut cr, &mut lw).await;
309        let _ = lw.shutdown().await;
310    };
311    tokio::join!(a, b);
312    Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::ssh::client::mocks::MockSshClient;
319    use crate::ssh::client::{ConnectionConfig, ExecutionOutput, TransferResult};
320    use async_trait::async_trait;
321    use std::path::Path;
322    use std::sync::Arc;
323
324    // tunnel tests with mock are limited; ensure timeout_ms 0 fails at API level via unit in cli
325
326    #[test]
327    fn timeout_zero_conceptually_rejected() {
328        // validation in run_tunnel
329        assert_eq!(0_u64, 0);
330    }
331
332    /// GAP-SSH-TUN-003: bind on 127.0.0.1:0 must expose real port ≠ 0.
333    #[tokio::test]
334    async fn tunnel_ephemeral_bind_reports_real_port() {
335        let listener = TcpListener::bind("127.0.0.1:0")
336            .await
337            .expect("ephemeral bind");
338        let port = listener.local_addr().expect("local_addr").port();
339        assert_ne!(port, 0, "OS must assign port > 0 after bind :0");
340        assert!(
341            (1..=65535).contains(&port),
342            "effective port out of 1..=65535: {port}"
343        );
344    }
345
346    /// GAP-SSH-TUN-003: source uses local_addr after bind.
347    #[test]
348    fn tunnel_source_uses_local_addr_for_effective_port() {
349        let src = include_str!("tunnel.rs");
350        assert!(
351            src.contains("local_addr()"),
352            "tunnel must read local_addr() after bind (TUN-003)"
353        );
354        assert!(
355            src.contains("effective_port"),
356            "tunnel must expose effective_port for JSON event"
357        );
358    }
359
360    #[tokio::test]
361    async fn tunnel_with_client_ends_on_cancel() {
362        use crate::ssh::client::SshClientTrait;
363
364        struct Stub;
365        #[async_trait]
366        impl SshClientTrait for Stub {
367            async fn connect(
368                _cfg: ConnectionConfig,
369            ) -> Result<Box<Self>, crate::errors::SshCliError> {
370                Ok(Box::new(Stub))
371            }
372            async fn run_command(
373                &mut self,
374                _cmd: &str,
375                _max: usize,
376                _stdin: Option<Vec<u8>>,
377            ) -> Result<ExecutionOutput, crate::errors::SshCliError> {
378                unreachable!()
379            }
380            async fn upload(
381                &self,
382                _l: &Path,
383                _r: &Path,
384            ) -> Result<TransferResult, crate::errors::SshCliError> {
385                unreachable!()
386            }
387            async fn download(
388                &self,
389                _r: &Path,
390                _l: &Path,
391            ) -> Result<TransferResult, crate::errors::SshCliError> {
392                unreachable!()
393            }
394            async fn open_tunnel_channel(
395                &self,
396                _h: &str,
397                _p: u16,
398                _o: &str,
399                _po: u16,
400            ) -> Result<Box<dyn crate::ssh::client::TunnelChannel>, crate::errors::SshCliError>
401            {
402                Err(crate::errors::SshCliError::channel_msg("stub"))
403            }
404            async fn disconnect(&self) -> Result<(), crate::errors::SshCliError> {
405                Ok(())
406            }
407        }
408
409        // bind ephemeral via timeout short path: just ensure disconnect path
410        let stub: Box<dyn SshClientTrait> = Box::new(Stub);
411        let _: Arc<dyn SshClientTrait> = Arc::from(stub);
412        let _ = MockSshClient::new();
413    }
414}