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 tunnelling with a mandatory deadline (bounded one-shot).
5//!
6//! Four modes share this entry point, split across submodules because they share
7//! a lifecycle but not a data path:
8//!
9//! | Mode | Who listens | Submodule |
10//! |---|---|---|
11//! | local forward | this process | `local` |
12//! | SOCKS5 proxy | this process | `local` + `socks` |
13//! | remote Unix socket | this process | `local` + `streamlocal` |
14//! | reverse forward | the SSH server | `reverse` |
15//!
16//! What lives *here* is everything the modes genuinely share: the deadline
17//! wrapper, the counters it reads after cancellation, the exposure guards, and
18//! the two helpers (`pump`, `drain_forwards`) that every mode ends up calling.
19
20mod local;
21mod reverse;
22mod socks;
23mod stats;
24mod streamlocal;
25
26pub use local::ForwardKind;
27pub use stats::TunnelStats;
28pub use streamlocal::validate_remote_socket;
29
30use crate::errors::SshCliError;
31use crate::output;
32use crate::ssh::client::{SshClient, SshClientTrait};
33use crate::vps::find_by_name;
34use anyhow::Result;
35use std::path::PathBuf;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Arc;
38use std::time::Duration;
39
40/// Which tunnel the caller asked for.
41#[derive(Debug, Clone)]
42pub enum TunnelMode {
43    /// Local listener forwarding to a fixed remote `host:port`.
44    Local {
45        /// Remote host, resolved by the SSH server.
46        remote_host: String,
47        /// Remote port.
48        remote_port: u16,
49    },
50    /// Local listener speaking SOCKS5; the client names a target per connection.
51    Socks5,
52    /// Local listener forwarding to a remote Unix domain socket.
53    StreamLocal {
54        /// Absolute path of the socket on the remote host.
55        socket_path: String,
56    },
57    /// Server-side listener delivering connections back to a local port.
58    Reverse {
59        /// Address the server is asked to bind.
60        remote_bind: String,
61        /// Port the server is asked to bind (`0` = server allocates).
62        remote_port: u16,
63    },
64}
65
66impl TunnelMode {
67    /// Wire label used in `tunnel_listening` / `tunnel_closed`.
68    #[must_use]
69    pub fn label(&self) -> &'static str {
70        match self {
71            Self::Local { .. } => "local",
72            Self::Socks5 => "socks5",
73            Self::StreamLocal { .. } => "streamlocal",
74            Self::Reverse { .. } => "reverse",
75        }
76    }
77}
78
79/// SSH credential overrides for one tunnel invocation.
80#[derive(Default)]
81pub struct TunnelAuth {
82    /// Password override.
83    pub password: Option<secrecy::SecretString>,
84    /// Private key path override.
85    pub key: Option<String>,
86    /// Key passphrase override.
87    pub key_passphrase: Option<secrecy::SecretString>,
88    /// Authenticate through an SSH agent.
89    pub use_agent: bool,
90    /// Explicit agent socket path.
91    pub agent_socket: Option<String>,
92}
93
94/// Everything one `tunnel` invocation needs.
95///
96/// Grouped into a struct rather than passed as fifteen positional arguments:
97/// with that many `u16`/`bool`/`Option<String>` parameters in a row, a
98/// transposition compiles cleanly and only shows up as a tunnel pointing
99/// somewhere unintended.
100pub struct TunnelRequest {
101    /// Registry name of the host.
102    pub vps_name: String,
103    /// Local port to bind (`0` = ephemeral) — for reverse, the local target port.
104    pub local_port: u16,
105    /// Tunnel mode.
106    pub mode: TunnelMode,
107    /// Alternate config directory.
108    pub config_override: Option<PathBuf>,
109    /// Credential overrides.
110    pub auth: TunnelAuth,
111    /// Mandatory deadline in milliseconds.
112    pub timeout_ms: u64,
113    /// Replace a diverging host key in TOFU `known_hosts`.
114    pub replace_host_key: bool,
115    /// Agent-first JSON output.
116    pub json: bool,
117    /// Local bind address (ignored in reverse mode, where the server binds).
118    pub bind_addr: String,
119    /// Explicit acknowledgement that a routable bind exposes the service.
120    pub accept_network_exposure: bool,
121}
122
123/// Runs the `tunnel` subcommand with a mandatory timeout.
124///
125/// # Errors
126/// [`SshCliError::InvalidArgument`] for a zero deadline, an unacknowledged
127/// routable bind or an invalid remote socket; [`SshCliError::VpsNotFound`] for an
128/// unknown host; [`SshCliError::SshTimeout`] when the deadline expires *before*
129/// the listener is up.
130pub async fn run_tunnel(request: TunnelRequest) -> Result<()> {
131    let TunnelRequest {
132        vps_name,
133        local_port,
134        mode,
135        config_override,
136        auth,
137        timeout_ms,
138        replace_host_key,
139        json,
140        bind_addr,
141        accept_network_exposure,
142    } = request;
143
144    if timeout_ms == 0 {
145        return Err(SshCliError::InvalidArgument(
146            "tunnel requires --timeout-ms > 0 (bounded one-shot)".to_string(),
147        )
148        .into());
149    }
150
151    // G-TUN-R13: binding outside loopback publishes the forwarded remote service to
152    // the whole local network with no additional authentication. The default is
153    // loopback for exactly that reason, but any address used to be accepted in
154    // silence — no prompt, no warning, not even a record in the JSON event. For an
155    // agent-driven CLI a mis-inferred flag could expose a production database.
156    // This mirrors the explicit-risk gate the project already applies to
157    // `--replace-host-key`, and it fails before any network I/O is paid for.
158    match &mode {
159        TunnelMode::Reverse { remote_bind, .. } => {
160            // In reverse mode the exposed surface is the *server's* listener, so
161            // guarding the local bind would check the wrong end entirely.
162            guard_remote_exposure(remote_bind, accept_network_exposure)?;
163        }
164        _ => guard_network_exposure(&bind_addr, accept_network_exposure)?,
165    }
166    if let TunnelMode::StreamLocal { socket_path } = &mode {
167        validate_remote_socket(socket_path)?;
168    }
169
170    let vps = find_by_name(config_override.as_deref(), &vps_name)?
171        .ok_or_else(|| SshCliError::VpsNotFound(vps_name.clone()))?;
172
173    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
174    let cfg = resolve_tunnel_connection(vps, auth, Some(&path), replace_host_key);
175
176    tracing::info!(
177        vps = %vps_name,
178        local_port,
179        mode = mode.label(),
180        timeout_ms,
181        "starting SSH tunnel with deadline"
182    );
183
184    // GAP-SSH-IO-006: banners only on human TTY; agents/pipes do not pollute stdout.
185    // GAP-SSH-IO-008: in JSON, zero prose — structured event after bind.
186    // Banner with effective port is post-bind (TUN-003: port 0 is ephemeral).
187    if !json {
188        // `TunnelPressCtrlC` already existed and was bypassed by an English literal,
189        // so its pt-BR translation was unreachable — a translated string nobody could
190        // ever see. Routing through it is the fix; a second near-identical variant
191        // would have preserved the duplication instead of removing it.
192        output::print_human_banner(&crate::i18n::t(crate::i18n::Message::TunnelPressCtrlC));
193    }
194
195    // GAP-SSH-TUN-001: deadline covers connect + loop (not only the accept loop).
196    // GAP-SSH-TUN-002: if the local listener is already up, deadline end is one-shot success
197    // (not SshTimeout/exit 74). Timeout before bind (slow connect) remains an error.
198    // Interior mutability: Arc<AtomicBool> shares the "listener up" bit between
199    // the timeout wrapper and the accept loop (Release store / Acquire load).
200    // Not RefCell/Mutex — a single independent flag is enough.
201    let bound = Arc::new(AtomicBool::new(false));
202    let bound_flag = Arc::clone(&bound);
203    let stats = Arc::new(TunnelStats::default());
204    let stats_loop = Arc::clone(&stats);
205    let started = std::time::Instant::now();
206    let mode_label = mode.label();
207    let bind_for_event = match &mode {
208        TunnelMode::Reverse { remote_bind, .. } => remote_bind.clone(),
209        _ => bind_addr.clone(),
210    };
211
212    let result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
213        let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
214        serve_mode(
215            ServeContext {
216                // Cloned: `emit_closed` below still needs the name after this
217                // coroutine takes ownership.
218                vps_name: vps_name.clone(),
219                local_port,
220                timeout_ms,
221                json,
222                bind_addr,
223                bound_flag: Some(bound_flag),
224                stats: Some(stats_loop),
225            },
226            mode,
227            client,
228        )
229        .await
230    })
231    .await;
232
233    // G-TUN-R07: emitted on every ending. Placing this in the wrapper rather than in
234    // the loop is what makes the deadline path work at all — there the loop future is
235    // cancelled mid-poll and its own tail never runs.
236    let emit_closed = |reason| {
237        if json && bound.load(Ordering::Acquire) {
238            // B3: the printer now takes the already-built DTO, so the event is
239            // constructed exactly once and stays inspectable by tests.
240            let event = output::build_tunnel_closed(output::TunnelClosedInput {
241                vps: &vps_name,
242                reason,
243                bind: &bind_for_event,
244                local_port: u16::try_from(stats.effective_port.load(Ordering::Acquire))
245                    .unwrap_or(local_port),
246                forwards_served: stats.forwards_served.load(Ordering::Relaxed),
247                capacity_waits: stats.capacity_waits.load(Ordering::Relaxed),
248                duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
249                mode: mode_label,
250            });
251            // R10 shape: never discard the Result. This event is the *only* thing that
252            // distinguishes `deadline`, `signal` and `accept_error`, which all end with
253            // exit 0. Swallowing the error would hand an agent a successful exit with no
254            // way to learn which ending occurred, so the failure goes to stderr.
255            if let Err(e) = output::print_tunnel_closed_json(&event) {
256                tracing::warn!(err = %e, "failed to emit tunnel_closed event");
257            }
258        }
259    };
260
261    match result {
262        Ok(inner) => {
263            emit_closed(stats.close_reason());
264            inner
265        }
266        Err(_) if bound.load(Ordering::Acquire) => {
267            tracing::info!(timeout_ms, "tunnel ended by one-shot deadline (success)");
268            emit_closed(crate::json_wire::TunnelCloseReason::Deadline);
269            Ok(())
270        }
271        Err(_) => {
272            tracing::warn!(timeout_ms, "tunnel timeout before local bind");
273            Err(SshCliError::SshTimeout(timeout_ms).into())
274        }
275    }
276}
277
278/// Builds the connection config for a tunnel from an already-loaded registry record.
279///
280/// G-QA-R02: `run_tunnel` had dependency injection only for the accept loop, which was
281/// the part that needed a socket. Everything between the argument guards and that loop
282/// — override application and config assembly — stayed welded to `find_by_name`, so it
283/// could only be exercised against a real host. That is exactly the range where the E3
284/// bug lived: `--use-agent` and `--agent-socket` parsed, were dropped on the floor, and
285/// a host registered for agent auth simply could not open a tunnel while exec and scp
286/// could. Taking the record as an argument makes the whole range testable offline.
287///
288/// The registry's own `timeout` is deliberately *not* consulted: a tunnel is bounded by
289/// `--timeout-ms`, and letting the host record shorten or extend that would make the
290/// one-shot deadline depend on state the caller never mentioned.
291#[must_use]
292pub fn resolve_tunnel_connection(
293    mut vps: crate::vps::model::VpsRecord,
294    auth: TunnelAuth,
295    config_toml: Option<&std::path::Path>,
296    replace_host_key: bool,
297) -> crate::ssh::client::ConnectionConfig {
298    // GAP-SSH-CLI-005 / M3: parity with exec/scp via apply_overrides (password/key/passphrase).
299    crate::vps::apply_overrides(
300        &mut vps,
301        crate::vps::AuthOverrides {
302            password: auth.password,
303            key_path: auth.key,
304            key_passphrase: auth.key_passphrase,
305            use_agent: auth.use_agent,
306            agent_socket: auth.agent_socket,
307            // E3: the tunnel deliberately does NOT override the registry timeout.
308            // `--timeout-ms` is the tunnel's own deadline, not the SSH connect
309            // budget; conflating them silently shortened long-lived forwards.
310            ..Default::default()
311        },
312    );
313    crate::vps::build_connection_config(&vps, config_toml, replace_host_key)
314}
315
316/// Everything an already-connected client needs to serve one tunnel, minus the
317/// mode-specific destination.
318///
319/// # Why a struct (B3)
320///
321/// The three serve entry points below took nine and ten positional parameters,
322/// with `u16`, `u64` and `bool` sitting next to each other. `local_port` and
323/// `remote_port` are both `u16`; swapping them compiles and binds the wrong
324/// side of the tunnel. The one lint that measures this — `too_many_arguments` —
325/// was suppressed on all three, so nothing reported it.
326pub struct ServeContext {
327    /// Registry name of the relay host.
328    pub vps_name: String,
329    /// Local port to bind (`0` = OS-assigned).
330    pub local_port: u16,
331    /// Mandatory deadline in milliseconds.
332    pub timeout_ms: u64,
333    /// Agent-first JSON output.
334    pub json: bool,
335    /// Local bind address.
336    pub bind_addr: String,
337    /// Set once the listener is bound (readiness handshake for callers).
338    pub bound_flag: Option<Arc<AtomicBool>>,
339    /// Lifetime counters published in the `tunnel_closed` event.
340    pub stats: Option<Arc<TunnelStats>>,
341}
342
343/// Routes an already-connected client into the loop its mode requires.
344async fn serve_mode(
345    ctx: ServeContext,
346    mode: TunnelMode,
347    client: Box<dyn SshClientTrait>,
348) -> Result<()> {
349    let ServeContext {
350        vps_name,
351        local_port,
352        timeout_ms,
353        json,
354        bind_addr,
355        bound_flag,
356        stats,
357    } = ctx;
358    let (vps_name, bind_addr) = (vps_name.as_str(), bind_addr.as_str());
359    match mode {
360        TunnelMode::Reverse {
361            remote_bind,
362            remote_port,
363        } => {
364            reverse::serve(
365                reverse::ReverseServe {
366                    vps_name: vps_name.to_string(),
367                    remote_bind,
368                    remote_port,
369                    // The delivery target is always loopback: a reverse tunnel exists
370                    // to reach a service on *this* machine, and letting the remote
371                    // side steer us at an arbitrary local address would turn the
372                    // tunnel into an outbound port scanner.
373                    local_host: crate::constants::DEFAULT_TUNNEL_BIND_ADDR.to_string(),
374                    local_port,
375                    timeout_ms,
376                    json,
377                },
378                client,
379                bound_flag,
380                stats,
381            )
382            .await
383        }
384        other => {
385            let kind = match other {
386                TunnelMode::Local {
387                    remote_host,
388                    remote_port,
389                } => ForwardKind::Tcp {
390                    host: remote_host,
391                    port: remote_port,
392                },
393                TunnelMode::Socks5 => ForwardKind::Socks5,
394                TunnelMode::StreamLocal { socket_path } => ForwardKind::StreamLocal { socket_path },
395                TunnelMode::Reverse { .. } => unreachable!("handled by the arm above"),
396            };
397            local::serve(
398                local::LocalServe {
399                    vps_name: vps_name.to_string(),
400                    local_port,
401                    bind_addr: bind_addr.to_string(),
402                    timeout_ms,
403                    json,
404                    kind,
405                },
406                client,
407                bound_flag,
408                stats,
409            )
410            .await
411        }
412    }
413}
414
415/// Rejects a non-loopback bind unless the caller explicitly accepted the risk.
416///
417/// Pure and side-effect free so the policy is unit-testable without a socket.
418///
419/// # Errors
420/// [`SshCliError::InvalidArgument`] (exit 64) when the address is routable and
421/// `accepted` is false, or when the address cannot be parsed.
422pub fn guard_network_exposure(bind_addr: &str, accepted: bool) -> Result<(), SshCliError> {
423    let parsed: std::net::IpAddr = bind_addr.parse().map_err(|_| {
424        SshCliError::InvalidArgument(format!("invalid --bind address `{bind_addr}`"))
425    })?;
426    if parsed.is_loopback() || accepted {
427        if !parsed.is_loopback() {
428            tracing::warn!(
429                bind = %bind_addr,
430                "tunnel bound outside loopback: the forwarded remote service is reachable from the local network"
431            );
432        }
433        return Ok(());
434    }
435    Err(SshCliError::InvalidArgument(format!(
436        "--bind {bind_addr} exposes the forwarded service to the network; \
437         pass --i-accept-network-exposure to proceed"
438    )))
439}
440
441/// Rejects a reverse forward that would publish a *remote* listener.
442///
443/// The mirror of [`guard_network_exposure`] for the inverted direction. The
444/// address is not parsed as an IP because RFC 4254 also assigns meaning to names
445/// and to the empty string (all interfaces), so an IP parser would reject the
446/// very forms that matter most.
447///
448/// # Errors
449/// [`SshCliError::InvalidArgument`] (exit 64) when the remote bind is routable
450/// and `accepted` is false.
451pub fn guard_remote_exposure(remote_bind: &str, accepted: bool) -> Result<(), SshCliError> {
452    let loopback = matches!(remote_bind, "127.0.0.1" | "::1" | "localhost");
453    if loopback || accepted {
454        if !loopback {
455            tracing::warn!(
456                bind = %remote_bind,
457                "reverse tunnel bound outside remote loopback: the local service is reachable from the remote network"
458            );
459        }
460        return Ok(());
461    }
462    Err(SshCliError::InvalidArgument(format!(
463        "--reverse binding `{remote_bind}` on the server exposes your local service to \
464         the remote network; pass --i-accept-network-exposure to proceed"
465    )))
466}
467
468/// Copies bytes both ways until either side closes.
469///
470/// G-TUN-R10 / G-TUN-R11: the previous implementation ran two `tokio::io::copy`
471/// futures under `join!` and discarded both `Result`s with `let _ =`, so a
472/// connection that died mid-transfer was indistinguishable from one that finished
473/// cleanly — at any verbosity. `copy_bidirectional` returns the byte counts for
474/// both directions *and* the error, and already performs the EOF-triggered
475/// `shutdown()` on the opposing side that the manual version open-coded.
476///
477/// # Errors
478/// [`SshCliError::Io`] when the copy fails mid-stream.
479pub(crate) async fn pump<L>(
480    mut local: L,
481    mut channel: Box<dyn crate::ssh::client::TunnelChannel>,
482    peer: &str,
483    peer_port: u16,
484) -> Result<()>
485where
486    L: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
487{
488    match tokio::io::copy_bidirectional(&mut local, &mut *channel).await {
489        Ok((to_remote, to_local)) => {
490            tracing::debug!(
491                bytes_to_remote = to_remote,
492                bytes_to_local = to_local,
493                "tunnel forward completed"
494            );
495            Ok(())
496        }
497        Err(e) => {
498            // Surfaced as a warning rather than swallowed: "the tunnel connects but no
499            // data arrives" is undiagnosable when this path is silent.
500            tracing::warn!(err = %e, %peer, peer_port, "tunnel forward copy failed");
501            Err(SshCliError::Io(e).into())
502        }
503    }
504}
505
506/// Drains in-flight forwards with a bounded grace, aborting what will not finish.
507pub(crate) async fn drain_forwards(forwards: &mut tokio::task::JoinSet<()>) {
508    if crate::signals::is_force_exit() {
509        tracing::info!("force-exit: aborting tunnel forwards");
510        forwards.abort_all();
511    }
512    // Bounded drain: cooperative cancel gets a short grace; force already aborted.
513    let drain = tokio::time::timeout(
514        Duration::from_secs(crate::constants::TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS),
515        async { while forwards.join_next().await.is_some() {} },
516    )
517    .await;
518    if drain.is_err() {
519        tracing::warn!("tunnel forward drain timed out; aborting remainder");
520        forwards.abort_all();
521        while forwards.join_next().await.is_some() {}
522    }
523}
524
525/// Testable local-forward loop (see [`run_tunnel_with_client_stats`] for counters).
526///
527/// # Errors
528/// Propagates bind and forwarding failures from the local accept loop.
529pub async fn run_tunnel_with_client(
530    mut ctx: ServeContext,
531    remote_host: &str,
532    remote_port: u16,
533    client: Box<dyn SshClientTrait>,
534) -> Result<()> {
535    ctx.stats = None;
536    run_tunnel_with_client_stats(ctx, remote_host, remote_port, client).await
537}
538
539/// Testable local-forward loop that publishes lifetime counters into
540/// [`ServeContext::stats`].
541///
542/// # Errors
543/// Propagates bind and forwarding failures from the local accept loop.
544pub async fn run_tunnel_with_client_stats(
545    ctx: ServeContext,
546    remote_host: &str,
547    remote_port: u16,
548    client: Box<dyn SshClientTrait>,
549) -> Result<()> {
550    local::serve(
551        local::LocalServe {
552            vps_name: ctx.vps_name,
553            local_port: ctx.local_port,
554            bind_addr: ctx.bind_addr,
555            timeout_ms: ctx.timeout_ms,
556            json: ctx.json,
557            kind: ForwardKind::Tcp {
558                host: remote_host.to_string(),
559                port: remote_port,
560            },
561        },
562        client,
563        ctx.bound_flag,
564        ctx.stats,
565    )
566    .await
567}
568
569#[cfg(test)]
570mod tests;