Skip to main content

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}