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;