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