1#![forbid(unsafe_code)]
4use 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#[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 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 if !json {
73 output::print_human_banner(
74 "Press Ctrl+C to stop the tunnel before the deadline.",
75 );
76 }
77
78 let bound = Arc::new(AtomicBool::new(false));
85 let bound_flag = Arc::clone(&bound);
86 let result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
87 let client: Box<dyn SshClientTrait> =
88 <SshClient as SshClientTrait>::connect(cfg).await?;
89 run_tunnel_with_client(
90 vps_name,
91 local_port,
92 remote_host,
93 remote_port,
94 timeout_ms,
95 json,
96 client,
97 Some(bound_flag),
98 bind_addr,
99 )
100 .await
101 })
102 .await;
103
104 match result {
105 Ok(inner) => inner,
106 Err(_) if bound.load(Ordering::Acquire) => {
107 tracing::info!(
108 timeout_ms,
109 "tunnel ended by one-shot deadline (success)"
110 );
111 Ok(())
112 }
113 Err(_) => {
114 tracing::warn!(timeout_ms, "tunnel timeout before local bind");
115 Err(SshCliError::SshTimeout(timeout_ms).into())
116 }
117 }
118}
119
120#[allow(clippy::too_many_arguments)]
122pub async fn run_tunnel_with_client(
123 vps_name: &str,
124 local_port: u16,
125 remote_host: &str,
126 remote_port: u16,
127 timeout_ms: u64,
128 json: bool,
129 client: Box<dyn SshClientTrait>,
130 bound_flag: Option<Arc<AtomicBool>>,
131 bind_addr: &str,
132) -> Result<()> {
133 let client: std::sync::Arc<dyn SshClientTrait> = std::sync::Arc::from(client);
134
135 let bind_target = format!("{bind_addr}:{local_port}");
136 let listener = TcpListener::bind(&bind_target).await.map_err(|e| {
137 SshCliError::Config(format!(
138 "failed to bind local address {bind_target}: {e}"
139 ))
140 })?;
141
142 let effective_port = listener
145 .local_addr()
146 .map(|a| a.port())
147 .unwrap_or(local_port);
148
149 if let Some(flag) = bound_flag.as_ref() {
150 flag.store(true, Ordering::Release);
152 }
153
154 tracing::info!(port = %effective_port, requested = %local_port, vps = %vps_name, "local TCP listener started");
155
156 if json {
159 output::print_tunnel_listening_json(
160 vps_name,
161 effective_port,
162 remote_host,
163 remote_port,
164 timeout_ms,
165 )?;
166 } else {
167 let banner = format!(
168 "Tunnel SSH: localhost:{} -> {}:{} via {} (timeout {}ms)",
169 effective_port, remote_host, remote_port, vps_name, timeout_ms
170 );
171 tracing::info!("{banner}");
172 output::print_human_banner(&banner);
173 }
174
175 let mut forwards = tokio::task::JoinSet::new();
179 let forward_limit = crate::concurrency::effective_limit();
180 let forward_sem = crate::concurrency::semaphore(forward_limit);
181 tracing::debug!(
182 max_concurrency = forward_limit,
183 "tunnel forward admission gate ready"
184 );
185
186 loop {
187 if crate::signals::should_stop() {
188 tracing::info!(
189 force = crate::signals::is_force_exit(),
190 "tunnel cancelled by signal"
191 );
192 break;
193 }
194
195 tokio::select! {
196 accept_result = listener.accept() => {
197 match accept_result {
198 Ok((socket, addr)) => {
199 tracing::debug!(address = %addr, "new local connection");
200 if let Err(e) = socket.set_nodelay(true) {
202 tracing::debug!(err = %e, %addr, "tunnel set_nodelay failed");
203 }
204 let host = remote_host.to_string();
205 let client_c = Arc::clone(&client);
207 let permit = match forward_sem.clone().try_acquire_owned() {
210 Ok(p) => p,
211 Err(_) => {
212 tokio::select! {
214 p = crate::concurrency::acquire_owned(&forward_sem) => p,
215 Some(joined) = forwards.join_next() => {
216 if let Err(e) = joined {
217 tracing::debug!(err = %e, "tunnel forward task ended with join error");
218 }
219 crate::concurrency::acquire_owned(&forward_sem).await
220 }
221 }
222 }
223 };
224 forwards.spawn(async move {
225 let _permit = permit; if let Err(e) = forward(socket, client_c, &host, remote_port).await {
227 tracing::warn!(err = %e, "tunnel forwarding failed");
228 }
229 });
230 }
231 Err(e) => {
232 if matches!(
234 e.kind(),
235 std::io::ErrorKind::Interrupted
236 | std::io::ErrorKind::WouldBlock
237 | std::io::ErrorKind::ConnectionAborted
238 | std::io::ErrorKind::ConnectionReset
239 ) {
240 tracing::debug!(err = %e, "transient accept error; continuing");
241 continue;
242 }
243 tracing::error!(err = %e, "accept failed (fatal)");
244 break;
245 }
246 }
247 }
248 Some(joined) = forwards.join_next() => {
250 if let Err(e) = joined {
251 tracing::debug!(err = %e, "tunnel forward task ended with join error");
252 }
253 }
254 _ = tokio::time::sleep(Duration::from_millis(
255 crate::constants::TUNNEL_SIGNAL_POLL_INTERVAL_MS,
256 )) => {
257 }
259 }
260 }
261
262 drop(listener);
264 if crate::signals::is_force_exit() {
265 tracing::info!("force-exit: aborting tunnel forwards");
266 forwards.abort_all();
267 }
268 let drain = tokio::time::timeout(
270 Duration::from_secs(crate::constants::TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS),
271 async {
272 while forwards.join_next().await.is_some() {}
273 },
274 )
275 .await;
276 if drain.is_err() {
277 tracing::warn!("tunnel forward drain timed out; aborting remainder");
278 forwards.abort_all();
279 while forwards.join_next().await.is_some() {}
280 }
281
282 let _ = client.disconnect().await;
283 Ok(())
284}
285
286async fn forward(
287 mut local: tokio::net::TcpStream,
288 client: std::sync::Arc<dyn SshClientTrait>,
289 remote_host: &str,
290 remote_port: u16,
291) -> Result<()> {
292 use tokio::io::AsyncWriteExt;
293 let mut canal = client
294 .open_tunnel_channel(
295 remote_host,
296 remote_port,
297 crate::constants::TUNNEL_CHANNEL_ORIGIN_ADDR,
298 crate::constants::TUNNEL_CHANNEL_ORIGIN_PORT,
299 )
300 .await?;
301 let (mut lr, mut lw) = local.split();
302 let (mut cr, mut cw) = tokio::io::split(&mut *canal);
303 let a = async {
304 let _ = tokio::io::copy(&mut lr, &mut cw).await;
305 let _ = cw.shutdown().await;
306 };
307 let b = async {
308 let _ = tokio::io::copy(&mut cr, &mut lw).await;
309 let _ = lw.shutdown().await;
310 };
311 tokio::join!(a, b);
312 Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::ssh::client::mocks::MockSshClient;
319 use crate::ssh::client::{ConnectionConfig, ExecutionOutput, TransferResult};
320 use async_trait::async_trait;
321 use std::path::Path;
322 use std::sync::Arc;
323
324 #[test]
327 fn timeout_zero_conceptually_rejected() {
328 assert_eq!(0_u64, 0);
330 }
331
332 #[tokio::test]
334 async fn tunnel_ephemeral_bind_reports_real_port() {
335 let listener = TcpListener::bind("127.0.0.1:0")
336 .await
337 .expect("ephemeral bind");
338 let port = listener.local_addr().expect("local_addr").port();
339 assert_ne!(port, 0, "OS must assign port > 0 after bind :0");
340 assert!(
341 (1..=65535).contains(&port),
342 "effective port out of 1..=65535: {port}"
343 );
344 }
345
346 #[test]
348 fn tunnel_source_uses_local_addr_for_effective_port() {
349 let src = include_str!("tunnel.rs");
350 assert!(
351 src.contains("local_addr()"),
352 "tunnel must read local_addr() after bind (TUN-003)"
353 );
354 assert!(
355 src.contains("effective_port"),
356 "tunnel must expose effective_port for JSON event"
357 );
358 }
359
360 #[tokio::test]
361 async fn tunnel_with_client_ends_on_cancel() {
362 use crate::ssh::client::SshClientTrait;
363
364 struct Stub;
365 #[async_trait]
366 impl SshClientTrait for Stub {
367 async fn connect(
368 _cfg: ConnectionConfig,
369 ) -> Result<Box<Self>, crate::errors::SshCliError> {
370 Ok(Box::new(Stub))
371 }
372 async fn run_command(
373 &mut self,
374 _cmd: &str,
375 _max: usize,
376 _stdin: Option<Vec<u8>>,
377 ) -> Result<ExecutionOutput, crate::errors::SshCliError> {
378 unreachable!()
379 }
380 async fn upload(
381 &self,
382 _l: &Path,
383 _r: &Path,
384 ) -> Result<TransferResult, crate::errors::SshCliError> {
385 unreachable!()
386 }
387 async fn download(
388 &self,
389 _r: &Path,
390 _l: &Path,
391 ) -> Result<TransferResult, crate::errors::SshCliError> {
392 unreachable!()
393 }
394 async fn open_tunnel_channel(
395 &self,
396 _h: &str,
397 _p: u16,
398 _o: &str,
399 _po: u16,
400 ) -> Result<Box<dyn crate::ssh::client::TunnelChannel>, crate::errors::SshCliError>
401 {
402 Err(crate::errors::SshCliError::channel_msg("stub"))
403 }
404 async fn disconnect(&self) -> Result<(), crate::errors::SshCliError> {
405 Ok(())
406 }
407 }
408
409 let stub: Box<dyn SshClientTrait> = Box::new(Stub);
411 let _: Arc<dyn SshClientTrait> = Arc::from(stub);
412 let _ = MockSshClient::new();
413 }
414}