Skip to main content

ssh_cli/tunnel/
local.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe`.
3#![forbid(unsafe_code)]
4//! Local-listener tunnel modes: plain forward, SOCKS5 proxy, remote Unix socket.
5//!
6//! All three bind a local TCP listener and hand every accepted connection to an
7//! SSH channel, so they share one accept loop. Only the *destination* differs,
8//! which is what [`ForwardKind`] selects. Copying the loop three times would have
9//! meant maintaining the signal handling, the admission gate, the drain and the
10//! saturation accounting in triplicate — and those are precisely the parts that
11//! are easy to get subtly wrong in one copy and not the others.
12
13use super::TunnelStats;
14use crate::errors::SshCliError;
15use crate::output;
16use crate::ssh::client::SshClientTrait;
17use anyhow::Result;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::net::TcpListener;
22
23/// What an accepted local connection is forwarded to.
24#[derive(Debug, Clone)]
25pub enum ForwardKind {
26    /// Fixed `host:port` on the remote side (`direct-tcpip`).
27    Tcp {
28        /// Remote host, resolved by the SSH server.
29        host: String,
30        /// Remote port.
31        port: u16,
32    },
33    /// SOCKS5 proxy: the client names a target per connection (G-TUN-R02).
34    Socks5,
35    /// Remote Unix domain socket (`direct-streamlocal`, G-TUN-R03).
36    StreamLocal {
37        /// Absolute path of the socket on the remote host.
38        socket_path: String,
39    },
40}
41
42impl ForwardKind {
43    /// Wire label used in the `tunnel_listening` / `tunnel_closed` events.
44    #[must_use]
45    pub fn mode_label(&self) -> &'static str {
46        match self {
47            Self::Tcp { .. } => "local",
48            Self::Socks5 => "socks5",
49            Self::StreamLocal { .. } => "streamlocal",
50        }
51    }
52
53    /// Host reported in the listening event (`*` when chosen per connection).
54    #[must_use]
55    pub fn event_host(&self) -> String {
56        match self {
57            Self::Tcp { host, .. } => host.clone(),
58            // A SOCKS5 proxy has no single destination; reporting one would be a
59            // guess an agent could act on.
60            Self::Socks5 => "*".to_string(),
61            Self::StreamLocal { socket_path } => socket_path.clone(),
62        }
63    }
64
65    /// Port reported in the listening event (`0` when not applicable).
66    #[must_use]
67    pub fn event_port(&self) -> u16 {
68        match self {
69            Self::Tcp { port, .. } => *port,
70            Self::Socks5 | Self::StreamLocal { .. } => 0,
71        }
72    }
73}
74
75/// Everything the accept loop needs, grouped so the signature stays readable.
76pub struct LocalServe {
77    /// Registry name of the host, echoed into events.
78    pub vps_name: String,
79    /// Requested local port (`0` asks the OS to allocate).
80    pub local_port: u16,
81    /// Local bind address.
82    pub bind_addr: String,
83    /// Deadline echoed into the listening event.
84    pub timeout_ms: u64,
85    /// Agent-first JSON output.
86    pub json: bool,
87    /// Destination selector.
88    pub kind: ForwardKind,
89}
90
91/// Binds locally and forwards every accepted connection until signal or drop.
92///
93/// # Errors
94/// Bind failures, classified by [`std::io::ErrorKind`] so a busy port (retryable)
95/// stays distinguishable from a malformed address (never retryable).
96pub async fn serve(
97    params: LocalServe,
98    client: Box<dyn SshClientTrait>,
99    bound_flag: Option<Arc<AtomicBool>>,
100    stats: Option<Arc<TunnelStats>>,
101) -> Result<()> {
102    let stats = stats.unwrap_or_default();
103    let client: Arc<dyn SshClientTrait> = Arc::from(client);
104    let LocalServe {
105        vps_name,
106        local_port,
107        bind_addr,
108        timeout_ms,
109        json,
110        kind,
111    } = params;
112
113    let bind_target = format!("{bind_addr}:{local_port}");
114    // G-TUN-R09: every bind failure used to collapse into `Config` (exit 65, classified
115    // permanent), so an agent got "data error" for three situations needing opposite
116    // responses. Inspecting `ErrorKind` keeps the distinction: a busy port is worth
117    // retrying on another port, a malformed address never is, and formatting the
118    // `io::Error` into a String would have destroyed the very information that decides.
119    let listener = TcpListener::bind(&bind_target).await.map_err(|e| {
120        let kind = e.kind();
121        match kind {
122            std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::InvalidInput => {
123                SshCliError::InvalidArgument(format!("cannot bind {bind_target}: {e}"))
124            }
125            _ => SshCliError::Io(e),
126        }
127    })?;
128
129    // GAP-SSH-TUN-003: port 0 (ephemeral) must report the OS-assigned real port.
130    // Agents use `local_port` from the `tunnel_listening` event to connect.
131    let effective_port = listener
132        .local_addr()
133        .map(|a| a.port())
134        .unwrap_or(local_port);
135
136    // Published before the bound flag so a wrapper that observes `bound` can already
137    // read the real port.
138    stats
139        .effective_port
140        .store(u32::from(effective_port), Ordering::Release);
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!(
147        port = %effective_port,
148        requested = %local_port,
149        vps = %vps_name,
150        mode = kind.mode_label(),
151        "local TCP listener started"
152    );
153
154    // GAP-SSH-IO-008: agent receives structured confirmation that local bind is up.
155    // GAP-SSH-TUN-003: always report `effective_port` (not the requested port when 0).
156    if json {
157        output::print_tunnel_listening_json(
158            &vps_name,
159            effective_port,
160            &kind.event_host(),
161            kind.event_port(),
162            timeout_ms,
163            &bind_addr,
164            kind.mode_label(),
165        )?;
166    } else {
167        // E4: the banner hard-coded `localhost:` regardless of `--bind`, so a run
168        // bound to 0.0.0.0 still told the operator it was listening on loopback —
169        // the exact opposite of the security-relevant fact.
170        // Through `i18n::t` rather than an inline `format!`: these are human-facing
171        // strings, and the project keeps every one of them in a single exhaustive
172        // match so a new language cannot silently miss one.
173        let banner = crate::i18n::t(match &kind {
174            ForwardKind::Tcp { host, port } => crate::i18n::Message::TunnelLocalListening {
175                bind: bind_addr.clone(),
176                port: effective_port,
177                remote_host: host.clone(),
178                remote_port: *port,
179                vps: vps_name.clone(),
180                timeout_ms,
181            },
182            ForwardKind::Socks5 => crate::i18n::Message::TunnelSocks5Listening {
183                bind: bind_addr.clone(),
184                port: effective_port,
185                vps: vps_name.clone(),
186                timeout_ms,
187            },
188            ForwardKind::StreamLocal { socket_path } => {
189                crate::i18n::Message::TunnelStreamLocalListening {
190                    bind: bind_addr.clone(),
191                    port: effective_port,
192                    socket_path: socket_path.clone(),
193                    vps: vps_name.clone(),
194                    timeout_ms,
195                }
196            }
197        });
198        tracing::info!("{banner}");
199        output::print_human_banner(&banner);
200    }
201
202    // Track forwards so shutdown can drain/abort instead of detaching `tokio::spawn`.
203    // Admission gate: Semaphore (Rules Rust — never unbounded spawn on accept).
204    // Workload: I/O-bound bidirectional copy; saturates FDs + SSH channels.
205    let mut forwards = tokio::task::JoinSet::new();
206    let forward_limit = crate::concurrency::effective_limit();
207    let forward_sem = crate::concurrency::semaphore(forward_limit);
208    tracing::debug!(
209        max_concurrency = forward_limit,
210        "tunnel forward admission gate ready"
211    );
212
213    loop {
214        if crate::signals::should_stop() {
215            tracing::info!(
216                force = crate::signals::is_force_exit(),
217                "tunnel cancelled by signal"
218            );
219            stats.stopped_by_signal.store(true, Ordering::Release);
220            break;
221        }
222
223        tokio::select! {
224            accept_result = listener.accept() => {
225                match accept_result {
226                    Ok((socket, addr)) => {
227                        tracing::debug!(address = %addr, "new local connection");
228                        // G-NET: low-latency local forward (Nagle off on accepted peer).
229                        if let Err(e) = socket.set_nodelay(true) {
230                            tracing::debug!(err = %e, %addr, "tunnel set_nodelay failed");
231                        }
232                        let kind_c = kind.clone();
233                        // Explicit Arc::clone: refcount only (not deep clone of the client).
234                        let client_c = Arc::clone(&client);
235                        // Block new accepts from over-subscribing: acquire before spawn,
236                        // interleaved with join_next via try_acquire + wait path below.
237                        let permit = match forward_sem.clone().try_acquire_owned() {
238                            Ok(p) => p,
239                            Err(_) => {
240                                // G-TUN-R12: record and announce saturation. Previously the
241                                // wait was completely silent, so the only symptom was rising
242                                // latency with no stated cause — and the operator had no way
243                                // to know the bottleneck was their own --max-concurrency.
244                                let prior = stats
245                                    .capacity_waits
246                                    .fetch_add(1, Ordering::Relaxed);
247                                if prior == 0 {
248                                    tracing::warn!(
249                                        max_concurrency = forward_limit,
250                                        "tunnel forward concurrency saturated; new connections are queuing"
251                                    );
252                                }
253                                // At capacity: wait for a permit or a completed forward.
254                                tokio::select! {
255                                    p = crate::concurrency::acquire_owned(&forward_sem) => p,
256                                    Some(joined) = forwards.join_next() => {
257                                        if let Err(e) = joined {
258                                            tracing::debug!(err = %e, "tunnel forward task ended with join error");
259                                        }
260                                        crate::concurrency::acquire_owned(&forward_sem).await
261                                    }
262                                }
263                            }
264                        };
265                        let served = Arc::clone(&stats);
266                        forwards.spawn(async move {
267                            let _permit = permit; // RAII release on task end
268                            served.forwards_served.fetch_add(1, Ordering::Relaxed);
269                            if let Err(e) = handle_connection(socket, client_c, &kind_c).await {
270                                tracing::warn!(err = %e, "tunnel forwarding failed");
271                            }
272                        });
273                    }
274                    Err(e) => {
275                        // G-NET: do not tear down the accept loop on transient errors.
276                        if matches!(
277                            e.kind(),
278                            std::io::ErrorKind::Interrupted
279                                | std::io::ErrorKind::WouldBlock
280                                | std::io::ErrorKind::ConnectionAborted
281                                | std::io::ErrorKind::ConnectionReset
282                        ) {
283                            tracing::debug!(err = %e, "transient accept error; continuing");
284                            continue;
285                        }
286                        tracing::error!(err = %e, "accept failed (fatal)");
287                        // G-TUN-R07: this ends the loop while `bound` is already true,
288                        // so the deadline wrapper returns Ok and the process exits 0.
289                        // Recording the reason is what lets an agent tell a tunnel that
290                        // died three seconds in from one that served its full lifetime.
291                        stats.stopped_by_accept_error.store(true, Ordering::Release);
292                        break;
293                    }
294                }
295            }
296            // Reap completed forwards so JoinSet does not grow unbounded.
297            Some(joined) = forwards.join_next() => {
298                if let Err(e) = joined {
299                    tracing::debug!(err = %e, "tunnel forward task ended with join error");
300                }
301            }
302            _ = tokio::time::sleep(Duration::from_millis(
303                crate::constants::TUNNEL_SIGNAL_POLL_INTERVAL_MS,
304            )) => {
305                // signal polling interval
306            }
307        }
308    }
309
310    // Stop accepting new local connections, then drain or abort active forwards.
311    drop(listener);
312    super::drain_forwards(&mut forwards).await;
313    let _ = client.disconnect().await;
314
315    // `tunnel_closed` is emitted by `run_tunnel`, not here: on the deadline path this
316    // future is cancelled mid-poll and never reaches this line.
317    Ok(())
318}
319
320/// Routes one accepted socket to the destination its mode dictates.
321async fn handle_connection(
322    socket: tokio::net::TcpStream,
323    client: Arc<dyn SshClientTrait>,
324    kind: &ForwardKind,
325) -> Result<()> {
326    match kind {
327        ForwardKind::Tcp { host, port } => {
328            let channel = client
329                .open_tunnel_channel(
330                    host,
331                    *port,
332                    crate::constants::TUNNEL_CHANNEL_ORIGIN_ADDR,
333                    crate::constants::TUNNEL_CHANNEL_ORIGIN_PORT,
334                )
335                .await?;
336            super::pump(socket, channel, host, *port).await
337        }
338        ForwardKind::StreamLocal { socket_path } => {
339            let channel = client.open_streamlocal_channel(socket_path).await?;
340            super::pump(socket, channel, socket_path, 0).await
341        }
342        ForwardKind::Socks5 => handle_socks5(socket, client).await,
343    }
344}
345
346/// Completes a SOCKS5 handshake, then pumps the negotiated channel.
347async fn handle_socks5(
348    mut socket: tokio::net::TcpStream,
349    client: Arc<dyn SshClientTrait>,
350) -> Result<()> {
351    use super::socks;
352
353    let target = match socks::handshake(&mut socket).await {
354        Ok(Ok(target)) => target,
355        Ok(Err(refusal)) => {
356            // The reply frame was already written by `handshake`; nothing further
357            // is owed to the client beyond a clean close.
358            tracing::debug!(reason = %refusal.reason, "SOCKS5 request refused");
359            return Ok(());
360        }
361        Err(e) => {
362            // Malformed input: the peer is not speaking SOCKS5, so a protocol reply
363            // would be meaningless. Drop the connection and say why in the log.
364            tracing::warn!(err = %e, "SOCKS5 handshake failed");
365            return Ok(());
366        }
367    };
368
369    let channel = match client
370        .open_tunnel_channel(
371            &target.host,
372            target.port,
373            crate::constants::TUNNEL_CHANNEL_ORIGIN_ADDR,
374            crate::constants::TUNNEL_CHANNEL_ORIGIN_PORT,
375        )
376        .await
377    {
378        Ok(channel) => channel,
379        Err(e) => {
380            // The client is waiting for a reply frame; closing here would leave it
381            // guessing between "refused" and "proxy died".
382            tracing::warn!(
383                err = %e, host = %target.host, port = target.port,
384                "SOCKS5 CONNECT could not open an SSH channel"
385            );
386            socks::write_reply(&mut socket, socks::REP_HOST_UNREACHABLE).await?;
387            return Ok(());
388        }
389    };
390
391    socks::write_reply(&mut socket, socks::REP_SUCCEEDED).await?;
392    super::pump(socket, channel, &target.host, target.port).await
393}