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