ssh_commander_core/ssh/tunnel.rs
1//! SSH local-port forwarding (`ssh -L`).
2//!
3//! Used by the Postgres explorer to tunnel a database connection through an
4//! SSH session, but the forwarder itself is protocol-agnostic and lives in the
5//! `ssh` module so the database layer needs no knowledge of SSH.
6//!
7//! Binds a local TCP listener on `127.0.0.1:<ephemeral>` and, for every
8//! inbound connection, opens a fresh `direct-tcpip` SSH channel to the
9//! configured remote endpoint and bidirectionally splices bytes between
10//! the local socket and the SSH channel. Pattern matches `ssh -L`.
11//!
12//! # Lifetime
13//!
14//! `SshTunnel` owns a `CancellationToken` and a `JoinHandle` for the
15//! accept loop. Dropping the tunnel cancels the loop and releases the
16//! local listener. Per-connection forwarder tasks are independent — they
17//! finish naturally when either side closes the stream — so the drop is
18//! best-effort: any in-flight Postgres traffic continues until the
19//! sockets close, which matches the observable behavior of `ssh -L`
20//! when the controlling terminal exits.
21//!
22//! # Concurrency
23//!
24//! The accept loop holds an `Arc<RwLock<SshClient>>`. Each accepted
25//! connection acquires a *read* lock for the duration of the
26//! `channel_open_direct_tcpip` round-trip only — the lock is dropped
27//! before splicing begins so the same SSH session can host many
28//! simultaneous Postgres connections without serializing channel
29//! opens.
30
31use std::sync::Arc;
32
33use tokio::io::AsyncWriteExt;
34use tokio::net::TcpListener;
35use tokio::sync::RwLock;
36use tokio::task::JoinHandle;
37use tokio_util::sync::CancellationToken;
38
39use serde::{Deserialize, Serialize};
40
41use super::SshClient;
42
43/// Reference to an existing `ConnectionManager`-managed SSH connection that
44/// should be used as a `direct-tcpip` tunnel for a Postgres connection.
45///
46/// Holding only the `connection_id` (rather than an `Arc<RwLock<SshClient>>`)
47/// keeps it purely data — the actual `SshClient` is resolved at connect time
48/// from the manager, so a tunnel can be re-established after SSH reconnect
49/// without rewriting the Postgres profile.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SshTunnelRef {
52 /// `ConnectionManager` ID of the SSH connection to tunnel through.
53 pub ssh_connection_id: String,
54 /// Host to forward to, as seen from the SSH server.
55 pub remote_host: String,
56 /// Port to forward to, as seen from the SSH server.
57 pub remote_port: u16,
58}
59
60/// Live SSH local-forward to `(remote_host, remote_port)`.
61///
62/// Public surface is intentionally tiny: the local port the caller
63/// should target, and an opaque drop guard. The accept loop, channel
64/// management, and byte splicing are private.
65pub struct SshTunnel {
66 /// Loopback port the local listener is bound to. Stable for the
67 /// lifetime of the tunnel.
68 local_port: u16,
69 cancel: CancellationToken,
70 /// Held only so the accept-loop task is aborted on drop. Never
71 /// awaited externally.
72 _accept_task: JoinHandle<()>,
73}
74
75impl SshTunnel {
76 pub fn local_port(&self) -> u16 {
77 self.local_port
78 }
79
80 /// Open the listener and start the accept loop. Returns once the
81 /// listener is bound; per-connection channels open lazily on
82 /// inbound traffic.
83 ///
84 /// Errors:
85 /// - `bind` fails (vanishingly rare on `127.0.0.1:0`)
86 /// - the SshClient is not connected (caller bug — should have
87 /// confirmed before invoking)
88 pub async fn open(
89 ssh_client: Arc<RwLock<SshClient>>,
90 remote_host: String,
91 remote_port: u16,
92 ) -> anyhow::Result<Self> {
93 // 0.0.0.0 would expose the forward to the LAN — `127.0.0.1` keeps
94 // it loopback-only, matching `ssh -L` defaults.
95 let listener = TcpListener::bind("127.0.0.1:0").await?;
96 let local_port = listener.local_addr()?.port();
97
98 let cancel = CancellationToken::new();
99 let task_cancel = cancel.clone();
100
101 let accept_task = tokio::spawn(async move {
102 run_accept_loop(listener, ssh_client, remote_host, remote_port, task_cancel).await;
103 });
104
105 Ok(Self {
106 local_port,
107 cancel,
108 _accept_task: accept_task,
109 })
110 }
111}
112
113impl Drop for SshTunnel {
114 fn drop(&mut self) {
115 // Cancellation is sufficient — the listener is owned by the
116 // accept task, so dropping the JoinHandle (with abort behavior)
117 // and signalling cancel both ensure the listener is closed.
118 self.cancel.cancel();
119 }
120}
121
122async fn run_accept_loop(
123 listener: TcpListener,
124 ssh_client: Arc<RwLock<SshClient>>,
125 remote_host: String,
126 remote_port: u16,
127 cancel: CancellationToken,
128) {
129 loop {
130 tokio::select! {
131 _ = cancel.cancelled() => {
132 tracing::debug!("postgres tunnel accept loop cancelled");
133 return;
134 }
135 res = listener.accept() => {
136 match res {
137 Ok((local_stream, peer)) => {
138 let ssh_client = ssh_client.clone();
139 let remote_host = remote_host.clone();
140 let conn_cancel = cancel.clone();
141 tokio::spawn(async move {
142 if let Err(e) = forward_one(
143 local_stream,
144 ssh_client,
145 &remote_host,
146 remote_port,
147 conn_cancel,
148 )
149 .await
150 {
151 tracing::warn!(
152 peer = %peer,
153 error = %e,
154 "postgres tunnel forwarder ended with error"
155 );
156 }
157 });
158 }
159 Err(e) => {
160 tracing::warn!("postgres tunnel accept failed: {e}");
161 // Don't tight-loop on a fatal listener error —
162 // a brief yield lets the runtime mark the
163 // listener dead, after which subsequent accepts
164 // also fail and we exit on cancel.
165 tokio::task::yield_now().await;
166 }
167 }
168 }
169 }
170 }
171}
172
173async fn forward_one(
174 mut local_stream: tokio::net::TcpStream,
175 ssh_client: Arc<RwLock<SshClient>>,
176 remote_host: &str,
177 remote_port: u16,
178 cancel: CancellationToken,
179) -> anyhow::Result<()> {
180 // Brief read-lock window: open the SSH channel, then drop the
181 // guard so other tasks can use the SshClient while we splice.
182 let channel = {
183 let guard = ssh_client.read().await;
184 guard.open_direct_tcpip(remote_host, remote_port).await?
185 };
186
187 let mut stream = channel.into_stream();
188 let (mut local_read, mut local_write) = local_stream.split();
189 let (mut ssh_read, mut ssh_write) = tokio::io::split(&mut stream);
190
191 // Bidirectional splice. `tokio::io::copy` returns when its source
192 // EOFs. Either direction finishing tears down both — Postgres
193 // connections are duplex and a half-open state is never useful.
194 let local_to_ssh = async {
195 let r = tokio::io::copy(&mut local_read, &mut ssh_write).await;
196 let _ = ssh_write.shutdown().await;
197 r
198 };
199 let ssh_to_local = async {
200 let r = tokio::io::copy(&mut ssh_read, &mut local_write).await;
201 let _ = local_write.shutdown().await;
202 r
203 };
204
205 tokio::select! {
206 _ = cancel.cancelled() => {
207 tracing::debug!("postgres tunnel forwarder cancelled");
208 Ok(())
209 }
210 res = async {
211 tokio::try_join!(local_to_ssh, ssh_to_local).map(|_| ())
212 } => {
213 res.map_err(anyhow::Error::from)
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 /// `SshTunnel::open` doesn't try to use the SSH client until a TCP
223 /// connection arrives. So binding the listener succeeds even when
224 /// the underlying SshClient is disconnected — which is the right
225 /// behavior: failure surfaces on first use, not on construction.
226 /// We can't easily build a real connected SshClient without a server,
227 /// but we can verify the bind step and the local port assignment.
228 #[tokio::test]
229 async fn open_binds_local_port_immediately() {
230 use crate::ssh::HostKeyStore;
231 let host_keys = Arc::new(HostKeyStore::new(
232 std::env::temp_dir().join("r-shell-tunnel-test-known-hosts"),
233 ));
234 let client = Arc::new(RwLock::new(SshClient::new(host_keys)));
235 let tunnel = SshTunnel::open(client, "irrelevant".to_string(), 5432)
236 .await
237 .expect("bind should succeed");
238 assert!(tunnel.local_port() > 0);
239 // Listener is reachable as long as the tunnel is alive.
240 let probe = tokio::net::TcpStream::connect(("127.0.0.1", tunnel.local_port())).await;
241 assert!(probe.is_ok(), "listener should accept connections");
242 }
243
244 /// Dropping the tunnel cancels the accept loop and releases the
245 /// listener. Asserted by binding a *new* listener on the same port
246 /// after drop — succeeds only if the original is gone.
247 #[tokio::test]
248 async fn drop_releases_local_port() {
249 use crate::ssh::HostKeyStore;
250 let host_keys = Arc::new(HostKeyStore::new(
251 std::env::temp_dir().join("r-shell-tunnel-test-known-hosts-2"),
252 ));
253 let client = Arc::new(RwLock::new(SshClient::new(host_keys)));
254 let tunnel = SshTunnel::open(client, "irrelevant".to_string(), 5432)
255 .await
256 .expect("bind");
257 let port = tunnel.local_port();
258 drop(tunnel);
259
260 // Give the runtime a tick to process the cancellation. SO_REUSEADDR
261 // is on by default for ephemeral ports on macOS/Linux, so a re-bind
262 // attempt is the cleanest assertion that the slot is free.
263 tokio::task::yield_now().await;
264 let rebind = TcpListener::bind(("127.0.0.1", port)).await;
265 assert!(rebind.is_ok(), "port {port} should be reusable after drop");
266 }
267}