1use crate::erros::SshCliError;
5use crate::output;
6use crate::ssh::client::{SshClient, SshClientTrait};
7use crate::vps::find_by_name;
8use anyhow::Result;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::net::TcpListener;
14
15#[allow(clippy::too_many_arguments)]
17pub async fn run_tunnel(
18 vps_name: &str,
19 local_port: u16,
20 remote_host: &str,
21 remote_port: u16,
22 config_override: Option<PathBuf>,
23 password_override: Option<String>,
24 key_override: Option<String>,
25 key_passphrase_override: Option<String>,
26 timeout_ms: u64,
27 replace_host_key: bool,
28 json: bool,
29 bind_addr: &str,
30) -> Result<()> {
31 if timeout_ms == 0 {
32 return Err(SshCliError::InvalidArgument(
33 "tunnel requires --timeout-ms > 0 (bounded one-shot)".to_string(),
34 )
35 .into());
36 }
37
38 let mut vps = find_by_name(config_override.clone(), vps_name)?
39 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
40
41 crate::vps::apply_overrides(
44 &mut vps,
45 password_override,
46 None,
47 None,
48 None,
49 key_override,
50 key_passphrase_override,
51 );
52
53 let path = crate::vps::resolve_config_path(config_override)?;
54 let cfg = crate::vps::build_connection_config(&vps, Some(&path), replace_host_key);
55
56 tracing::info!(
57 vps = %vps_name,
58 local_port,
59 remote_host,
60 remote_port,
61 timeout_ms,
62 "starting SSH tunnel with deadline"
63 );
64
65 if !json {
69 output::print_human_banner(
70 "Press Ctrl+C to stop the tunnel before the deadline.",
71 );
72 }
73
74 let bound = Arc::new(AtomicBool::new(false));
78 let bound_flag = Arc::clone(&bound);
79 let result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
80 let client: Box<dyn SshClientTrait> =
81 <SshClient as SshClientTrait>::connect(cfg).await?;
82 run_tunnel_with_client(
83 vps_name,
84 local_port,
85 remote_host,
86 remote_port,
87 timeout_ms,
88 json,
89 client,
90 Some(bound_flag),
91 bind_addr,
92 )
93 .await
94 })
95 .await;
96
97 match result {
98 Ok(inner) => inner,
99 Err(_) if bound.load(Ordering::SeqCst) => {
100 tracing::info!(
101 timeout_ms,
102 "tunnel ended by one-shot deadline (success)"
103 );
104 Ok(())
105 }
106 Err(_) => {
107 tracing::warn!(timeout_ms, "tunnel timeout before local bind");
108 Err(SshCliError::SshTimeout(timeout_ms).into())
109 }
110 }
111}
112
113#[allow(clippy::too_many_arguments)]
115pub async fn run_tunnel_with_client(
116 vps_name: &str,
117 local_port: u16,
118 remote_host: &str,
119 remote_port: u16,
120 timeout_ms: u64,
121 json: bool,
122 client: Box<dyn SshClientTrait>,
123 bound_flag: Option<Arc<AtomicBool>>,
124 bind_addr: &str,
125) -> Result<()> {
126 let client: std::sync::Arc<dyn SshClientTrait> = std::sync::Arc::from(client);
127
128 let bind_target = format!("{bind_addr}:{local_port}");
129 let listener = TcpListener::bind(&bind_target).await.map_err(|e| {
130 SshCliError::Generic(format!(
131 "failed to bind local address {bind_target}: {e}"
132 ))
133 })?;
134
135 let effective_port = listener
138 .local_addr()
139 .map(|a| a.port())
140 .unwrap_or(local_port);
141
142 if let Some(flag) = bound_flag.as_ref() {
143 flag.store(true, Ordering::SeqCst);
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 loop {
168 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
169 tracing::info!("tunnel cancelled by signal");
170 break;
171 }
172
173 tokio::select! {
174 accept_result = listener.accept() => {
175 match accept_result {
176 Ok((socket, addr)) => {
177 tracing::debug!(address = %addr, "new local connection");
178 let host = remote_host.to_string();
179 let client_c = client.clone();
180 tokio::spawn(async move {
181 if let Err(e) = forward(socket, client_c, &host, remote_port).await {
182 tracing::warn!(err = %e, "tunnel forwarding failed");
183 }
184 });
185 }
186 Err(e) => {
187 tracing::error!(err = %e, "accept failed");
188 break;
189 }
190 }
191 }
192 _ = tokio::time::sleep(Duration::from_millis(200)) => {
193 }
195 }
196 }
197
198 let _ = client.disconnect().await;
199 Ok(())
200}
201
202async fn forward(
203 mut local: tokio::net::TcpStream,
204 client: std::sync::Arc<dyn SshClientTrait>,
205 remote_host: &str,
206 remote_port: u16,
207) -> Result<()> {
208 use tokio::io::AsyncWriteExt;
209 let mut canal = client
210 .open_tunnel_channel(remote_host, remote_port, "127.0.0.1", 0)
211 .await?;
212 let (mut lr, mut lw) = local.split();
213 let (mut cr, mut cw) = tokio::io::split(&mut *canal);
214 let a = async {
215 let _ = tokio::io::copy(&mut lr, &mut cw).await;
216 let _ = cw.shutdown().await;
217 };
218 let b = async {
219 let _ = tokio::io::copy(&mut cr, &mut lw).await;
220 let _ = lw.shutdown().await;
221 };
222 tokio::join!(a, b);
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::ssh::client::mocks::MockSshClient;
230 use crate::ssh::client::{ConnectionConfig, ExecutionOutput, TransferResult};
231 use async_trait::async_trait;
232 use std::path::Path;
233 use std::sync::Arc;
234
235 #[test]
238 fn timeout_zero_conceptually_rejected() {
239 assert_eq!(0_u64, 0);
241 }
242
243 #[tokio::test]
245 async fn tunnel_ephemeral_bind_reports_real_port() {
246 let listener = TcpListener::bind("127.0.0.1:0")
247 .await
248 .expect("ephemeral bind");
249 let port = listener.local_addr().expect("local_addr").port();
250 assert_ne!(port, 0, "OS must assign port > 0 after bind :0");
251 assert!(
252 (1..=65535).contains(&port),
253 "effective port out of 1..=65535: {port}"
254 );
255 }
256
257 #[test]
259 fn tunnel_source_uses_local_addr_for_effective_port() {
260 let src = include_str!("tunnel.rs");
261 assert!(
262 src.contains("local_addr()"),
263 "tunnel must read local_addr() after bind (TUN-003)"
264 );
265 assert!(
266 src.contains("effective_port"),
267 "tunnel must expose effective_port for JSON event"
268 );
269 }
270
271 #[tokio::test]
272 async fn tunnel_with_client_ends_on_cancel() {
273 use crate::ssh::client::SshClientTrait;
274
275 struct Stub;
276 #[async_trait]
277 impl SshClientTrait for Stub {
278 async fn connect(
279 _cfg: ConnectionConfig,
280 ) -> Result<Box<Self>, crate::erros::SshCliError> {
281 Ok(Box::new(Stub))
282 }
283 async fn run_command(
284 &mut self,
285 _cmd: &str,
286 _max: usize,
287 _stdin: Option<Vec<u8>>,
288 ) -> Result<ExecutionOutput, crate::erros::SshCliError> {
289 unreachable!()
290 }
291 async fn upload(
292 &mut self,
293 _l: &Path,
294 _r: &Path,
295 ) -> Result<TransferResult, crate::erros::SshCliError> {
296 unreachable!()
297 }
298 async fn download(
299 &mut self,
300 _r: &Path,
301 _l: &Path,
302 ) -> Result<TransferResult, crate::erros::SshCliError> {
303 unreachable!()
304 }
305 async fn open_tunnel_channel(
306 &self,
307 _h: &str,
308 _p: u16,
309 _o: &str,
310 _po: u16,
311 ) -> Result<Box<dyn crate::ssh::client::TunnelChannel>, crate::erros::SshCliError>
312 {
313 Err(crate::erros::SshCliError::ChannelFailed("stub".into()))
314 }
315 async fn disconnect(&self) -> Result<(), crate::erros::SshCliError> {
316 Ok(())
317 }
318 }
319
320 let stub: Box<dyn SshClientTrait> = Box::new(Stub);
322 let _: Arc<dyn SshClientTrait> = Arc::from(stub);
323 let _ = MockSshClient::new();
324 }
325}