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}