From bb1346f887a9f1afa297696dd9b1d9e69b0edb12 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:33:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20native=20port=20forwarding=20?= =?UTF-8?q?=E2=80=94=20Local/Remote/Dynamic=20+=20loopback=20(WS4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the WS4 port-forwarding engine on top of WS2's native russh session engine. Forwards ride a pane's shared SshConnection (no ControlMaster socket), keyed per pane for the UI and torn down on pane death. Daemon engine (src/daemon/ssh/forward.rs): - Local (FR-F1): TCP listener -> per-conn direct-tcpip -> bidirectional bridge with exact EOF/close propagation. - Dynamic/SOCKS5 (FR-F1): hand-rolled minimal SOCKS5 (no-auth greeting, CONNECT for IPv4/IPv6/domain; BIND/UDP rejected) -> direct-tcpip. - Remote (FR-F1): tcpip_forward global request + RemoteForwardTable consulted by the client Handler's server_channel_open_forwarded_tcpip; unmatched channels rejected; cancel_tcpip_forward on teardown. - SshForwardRegistry keyed by pane_id; auto-teardown from DaemonPane::drop (covers the FR-C2 blast radius when a shared connection drops). - Preconfigured forwards (FR-F2) established post-auth in run_session; failures are non-fatal (ForwardStatus::Error rows, never a killed session). - Native loopback one-click (FR-F4): EnsureLoopbackForward branches on RemoteKind::NativeSsh to a Local direct-tcpip forward, same reply shape. Protocol: AddForward/RemoveForward/ListForwards (client kinds 20-22) -> ForwardList (daemon kind 20); ManagedForward/ForwardStatus wire types. Client: RemoteTerminal::{add,remove,list}_forward one-shots; view.rs can_forward_loopback also accepts native panes. UI (src/ui/forwards.rs): native panes show managed forwards (L/R/D badge, bind -> target, description, status, delete) + an add form with a segmented kind selector, alongside the existing loopback list; shell-out panes unchanged. X11 (FR-X2) left as a documented seam in daemon::ssh::handler (P1). Tests: SOCKS5 handshake (v4 reject, v5 CONNECT ipv4/domain/ipv6, BIND reject), bridge EOF both directions, registry add/remove/teardown, and protocol round-trips for the new messages. --- docs/ssh-native-architecture.md | 6 +- src/daemon/pane.rs | 13 +- src/daemon/protocol.rs | 146 +++++- src/daemon/server.rs | 91 +++- src/daemon/ssh/forward.rs | 865 ++++++++++++++++++++++++++++++++ src/daemon/ssh/handler.rs | 76 ++- src/daemon/ssh/mod.rs | 104 +++- src/daemon/ssh/session.rs | 66 ++- src/terminal/remote.rs | 52 +- src/terminal/view.rs | 11 +- src/ui/app.rs | 131 +++++ src/ui/forwards.rs | 231 ++++++++- src/ui/settings.rs | 2 +- 13 files changed, 1752 insertions(+), 42 deletions(-) create mode 100644 src/daemon/ssh/forward.rs diff --git a/docs/ssh-native-architecture.md b/docs/ssh-native-architecture.md index 364fac54..7b6490a5 100644 --- a/docs/ssh-native-architecture.md +++ b/docs/ssh-native-architecture.md @@ -169,9 +169,9 @@ brief §5); SFTP opens a session channel and drives the subsystem. | Seam | State in WS2 | Owner | |---|---|---| -| Port forwards (L/R/D) | `NativeSshSpec.forwards` carried only; `open_direct_tcpip` provided | WS4 | -| `RemoteContext.control_path` | always `None` for native — `forward.rs` (ssh `-O`) correctly rejects native panes | WS4 | -| X11 forwarding | `NativeSshSpec.x11` carried only; no X11 channels | WS4/WS5 | +| Port forwards (L/R/D) | **DONE (WS4)** — `daemon::ssh::forward` (`SshForwardRegistry`): Local/Dynamic TCP listeners + `open_direct_tcpip`, Remote via `tcpip_forward` + `RemoteForwardTable` in the handler; preconfigured forwards established post-auth in `run_session`; protocol `AddForward`/`RemoveForward`/`ListForwards` (client kinds 20–22) → `ForwardList` (daemon kind 20) | WS4 | +| `RemoteContext.control_path` | always `None` for native; native loopback (FR-F4) now goes through `SshManager::ensure_loopback_forward` (a Local `direct-tcpip`), server-side branch on `RemoteKind::NativeSsh` | WS4 | +| X11 forwarding | `NativeSshSpec.x11` carried only; **seam documented** in `daemon::ssh::handler` (P1, deferred — needs `request_x11` + `server_channel_open_x11` + `$DISPLAY` bridge) | WS4/WS5 | | SFTP | none; `open_session_channel` provided for the subsystem | WS5 | | Agent forwarding channels | `agent_forward` requests `auth-agent-req` on the shell channel; incoming agent-channel bridging to `SSH_AUTH_SOCK` not wired | WS4/WS5 | | Session restore respawn | `SessionPane::Leaf.ssh_spec` (secret-free) persisted; reconnection UX not built | WS6 | diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index d854a379..42e105e8 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -36,8 +36,8 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system} use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ - AuthResponse, DaemonMsg, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, SshSpec, - WinSize, + AuthResponse, DaemonMsg, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, + SshSpec, WinSize, }; use crate::daemon::shell_integration; @@ -786,6 +786,7 @@ impl DaemonPane { // Kick off the connection on the SSH engine's runtime. crate::daemon::ssh::SshManager::global().spawn_native_session( + id, spec, size, broker, @@ -1360,6 +1361,14 @@ impl DaemonPane { impl Drop for DaemonPane { fn drop(&mut self) { + // A native-SSH pane's managed forwards (WS4) are attributed to this pane; + // tear them down as the pane dies so listeners close and remote bindings are + // cancelled — the FR-C2 blast radius when a shared connection drops takes + // every pane through here. Detached, so it never blocks this connection + // thread. + if matches!(self.backend, PaneBackend::NativeSsh(_)) { + crate::daemon::ssh::SshManager::global().teardown_pane_forwards(self.id); + } // Hang up the byte source: SIGHUP → SIGKILL for a PTY child + its group, or // channel close for a native-SSH session — so the reader's `read()` can EOF. self.hangup(); diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 6f858144..44a55189 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -283,7 +283,7 @@ pub struct LoopbackForward { pub local_port: u16, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LoopbackForwardId { pub pane_id: u64, pub target: String, @@ -388,6 +388,38 @@ pub struct SshForwardRule { pub description: Option, } +/// Runtime status of a live managed forward, surfaced to the GUI per row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ForwardStatus { + /// The forward's listener (Local/Dynamic) or remote binding (Remote) is up. + Listening, + /// The forward failed to come up (bind conflict, remote request denied, …). + /// The string is a human-readable reason with no secrets. + Error(String), +} + +/// One established managed forward on a native-SSH pane's connection (WS4). This +/// is the runtime counterpart of a [`SshForwardRule`]: it carries a daemon-issued +/// `id` (used to remove it), the pane it is attributed to (for per-pane listing), +/// the *resolved* bind port (a `bind_port` of 0 resolves to the OS-assigned port), +/// and a live `status`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedForward { + pub id: u64, + pub pane_id: u64, + pub kind: SshForwardKind, + pub bind_host: String, + pub bind_port: u16, + #[serde(default)] + pub target_host: String, + #[serde(default)] + pub target_port: u16, + #[serde(default)] + pub description: Option, + pub status: ForwardStatus, +} + fn default_term() -> String { "xterm-256color".to_string() } @@ -567,7 +599,10 @@ pub enum AuthPromptKind { pub enum AuthResponse { Secret(String), Secrets(Vec), - HostKeyDecision { accept: bool, remember: bool }, + HostKeyDecision { + accept: bool, + remember: bool, + }, /// The user dismissed the prompt; the daemon fails the auth step cleanly. Cancelled, } @@ -653,6 +688,16 @@ pub enum ClientMsg { request_id: u64, response: AuthResponse, }, + /// Establish a new managed port-forward (Local/Remote/Dynamic) on the native-SSH + /// pane `pane_id`'s connection (WS4). Control-connection message; the daemon + /// replies with a `ForwardList` reflecting the pane's forwards after the add. + AddForward { pane_id: u64, rule: SshForwardRule }, + /// Tear down one managed forward by its daemon-issued id. Control-connection + /// message; the daemon replies with the pane's remaining `ForwardList`. + RemoveForward { pane_id: u64, forward_id: u64 }, + /// Ask for the managed forwards attributed to `pane_id`. Control-connection + /// message; the daemon replies with a `ForwardList`. + ListForwards { pane_id: u64 }, } /// Messages the daemon sends back to the GUI client. @@ -699,6 +744,9 @@ pub enum DaemonMsg { }, /// Progress of a native-SSH spawn (connect/auth/connected/failed). SshStatus { phase: SshPhase }, + /// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed + /// forwards currently attributed to the requested pane (WS4). + ForwardList(Vec), /// A request failed (e.g. `Attach` to an unknown/dead pane id). Error(String), } @@ -735,6 +783,13 @@ mod kind { pub const SPAWN_NATIVE_SSH: u8 = 14; /// `AuthResponse` — the GUI's reply to an `AUTH_PROMPT`. pub const AUTH_RESPONSE: u8 = 15; + // (16–19 reserved: WS3 auth extensions.) + /// `AddForward` — establish a managed port-forward (WS4). + pub const ADD_FORWARD: u8 = 20; + /// `RemoveForward` — tear down one managed forward by id (WS4). + pub const REMOVE_FORWARD: u8 = 21; + /// `ListForwards` — list a pane's managed forwards (WS4). + pub const LIST_FORWARDS: u8 = 22; // Daemon -> client pub const SPAWNED: u8 = 1; @@ -753,6 +808,9 @@ mod kind { pub const AUTH_PROMPT: u8 = 13; /// `SshStatus` — native-SSH spawn progress. pub const SSH_STATUS: u8 = 14; + // (15–19 reserved: WS3 auth extensions.) + /// `ForwardList` — reply to the WS4 managed-forward messages. + pub const FORWARD_LIST: u8 = 20; } /// Write one framed message: `[u32 LE len][u8 kind][payload]`. @@ -875,6 +933,16 @@ impl ClientMsg { request_id, response, } => write_frame(w, kind::AUTH_RESPONSE, &to_json(&(request_id, response))?), + ClientMsg::AddForward { pane_id, rule } => { + write_frame(w, kind::ADD_FORWARD, &to_json(&(pane_id, rule))?) + } + ClientMsg::RemoveForward { + pane_id, + forward_id, + } => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?), + ClientMsg::ListForwards { pane_id } => { + write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?) + } } } @@ -919,6 +987,20 @@ impl ClientMsg { response, } } + kind::ADD_FORWARD => { + let (pane_id, rule) = from_json(&payload)?; + ClientMsg::AddForward { pane_id, rule } + } + kind::REMOVE_FORWARD => { + let (pane_id, forward_id) = from_json(&payload)?; + ClientMsg::RemoveForward { + pane_id, + forward_id, + } + } + kind::LIST_FORWARDS => ClientMsg::ListForwards { + pane_id: from_json(&payload)?, + }, other => { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -964,6 +1046,7 @@ impl DaemonMsg { write_frame(w, kind::AUTH_PROMPT, &to_json(&(request_id, prompt))?) } DaemonMsg::SshStatus { phase } => write_frame(w, kind::SSH_STATUS, &to_json(phase)?), + DaemonMsg::ForwardList(list) => write_frame(w, kind::FORWARD_LIST, &to_json(list)?), DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?), } } @@ -1000,6 +1083,7 @@ impl DaemonMsg { kind::SSH_STATUS => DaemonMsg::SshStatus { phase: from_json(&payload)?, }, + kind::FORWARD_LIST => DaemonMsg::ForwardList(from_json(&payload)?), kind::ERROR => DaemonMsg::Error(from_json(&payload)?), other => { return Err(io::Error::new( @@ -1158,6 +1242,33 @@ mod tests { remote_host: "127.0.0.1".into(), remote_port: 3000, }), + ClientMsg::AddForward { + pane_id: 7, + rule: SshForwardRule { + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: 8080, + target_host: "10.0.0.5".into(), + target_port: 80, + description: Some("web".into()), + }, + }, + ClientMsg::AddForward { + pane_id: 7, + rule: SshForwardRule { + kind: SshForwardKind::Dynamic, + bind_host: "127.0.0.1".into(), + bind_port: 1080, + target_host: String::new(), + target_port: 0, + description: None, + }, + }, + ClientMsg::RemoveForward { + pane_id: 7, + forward_id: 3, + }, + ClientMsg::ListForwards { pane_id: 7 }, ]; let mut buf = Vec::new(); for m in &msgs { @@ -1280,6 +1391,30 @@ mod tests { age_secs: 12, idle_secs: 3, }]), + DaemonMsg::ForwardList(vec![ + ManagedForward { + id: 1, + pane_id: 7, + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: 8080, + target_host: "10.0.0.5".into(), + target_port: 80, + description: Some("web".into()), + status: ForwardStatus::Listening, + }, + ManagedForward { + id: 2, + pane_id: 7, + kind: SshForwardKind::Remote, + bind_host: "0.0.0.0".into(), + bind_port: 9000, + target_host: "127.0.0.1".into(), + target_port: 3000, + description: None, + status: ForwardStatus::Error("bind refused".into()), + }, + ]), DaemonMsg::Error("nope".into()), ]; let mut buf = Vec::new(); @@ -1567,10 +1702,9 @@ mod tests { /// Missing optional fields decode via `#[serde(default)]` (forward compat). #[test] fn native_ssh_spec_tolerates_minimal_json() { - let spec: NativeSshSpec = serde_json::from_str( - r#"{"host":"h","port":22,"user":"u","auth_mode":"auto"}"#, - ) - .unwrap(); + let spec: NativeSshSpec = + serde_json::from_str(r#"{"host":"h","port":22,"user":"u","auth_mode":"auto"}"#) + .unwrap(); assert_eq!(spec.term, "xterm-256color"); // defaulted assert!(spec.verify_host_keys); // defaulted true assert_eq!(spec.password, None); diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 3754fe80..f4abdd1f 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -24,7 +24,8 @@ use std::sync::mpsc::{self, Receiver}; use std::sync::{Arc, Mutex}; use crate::daemon::pane::DaemonPane; -use crate::daemon::protocol::{ClientMsg, DaemonMsg}; +use crate::daemon::protocol::{ClientMsg, DaemonMsg, RemoteKind}; +use crate::daemon::ssh::SshConnection; use crate::daemon::transport::{self, Stream}; /// Shared pane registry: id → pane, plus a monotonic id source. @@ -279,7 +280,8 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(p) => p, Err(e) => { let mut w = write_stream; - let _ = DaemonMsg::Error(format!("native ssh spawn failed: {e}")).encode(&mut w); + let _ = + DaemonMsg::Error(format!("native ssh spawn failed: {e}")).encode(&mut w); return Err(e); } }; @@ -346,12 +348,28 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .encode(&mut w)?; return Ok(()); }; - match crate::daemon::forward::ForwardManager::global().ensure( - req.pane_id, - &remote, - &req.remote_host, - req.remote_port, - ) { + // Native-SSH panes have no ControlMaster socket (FR-F4): create/reuse a + // Local `direct-tcpip` forward on the pane's russh connection instead, + // returning the same reply shape so the GUI's Cmd-click flow is unchanged. + let result = if remote.kind == RemoteKind::NativeSsh { + match pane.ssh_connection() { + Some(conn) => crate::daemon::ssh::SshManager::global() + .ensure_loopback_forward( + req.pane_id, + conn, + &remote.target, + &req.remote_host, + req.remote_port, + ) + .map_err(|e| e.to_string()), + None => Err("native ssh connection is not ready".to_string()), + } + } else { + crate::daemon::forward::ForwardManager::global() + .ensure(req.pane_id, &remote, &req.remote_host, req.remote_port) + .map_err(|e| e.to_string()) + }; + match result { Ok(forward) => DaemonMsg::LoopbackForward(forward).encode(&mut w)?, Err(e) => DaemonMsg::Error(format!("forward failed: {e}")).encode(&mut w)?, } @@ -360,19 +378,56 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { ClientMsg::ListLoopbackForwards => { let mut w = write_stream; - let list = crate::daemon::forward::ForwardManager::global().list(); + // The loopback panel shows both ControlMaster (compat-mode) and native + // russh loopback forwards. + let mut list = crate::daemon::forward::ForwardManager::global().list(); + list.extend(crate::daemon::ssh::SshManager::global().list_loopback_forwards()); DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; Ok(()) } ClientMsg::CloseLoopbackForward(id) => { let mut w = write_stream; - crate::daemon::forward::ForwardManager::global().close(&id); - let list = crate::daemon::forward::ForwardManager::global().list(); + // Try both backends; only one owns the id. + if !crate::daemon::forward::ForwardManager::global().close(&id) { + crate::daemon::ssh::SshManager::global().close_loopback_forward(&id); + } + let mut list = crate::daemon::forward::ForwardManager::global().list(); + list.extend(crate::daemon::ssh::SshManager::global().list_loopback_forwards()); DaemonMsg::LoopbackForwardList(list).encode(&mut w)?; Ok(()) } + ClientMsg::AddForward { pane_id, rule } => { + let mut w = write_stream; + match forward_pane_connection(®istry, pane_id) { + Ok(conn) => { + let list = + crate::daemon::ssh::SshManager::global().add_forward(pane_id, conn, &rule); + DaemonMsg::ForwardList(list).encode(&mut w)?; + } + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + + ClientMsg::RemoveForward { + pane_id, + forward_id, + } => { + let mut w = write_stream; + let list = crate::daemon::ssh::SshManager::global().remove_forward(pane_id, forward_id); + DaemonMsg::ForwardList(list).encode(&mut w)?; + Ok(()) + } + + ClientMsg::ListForwards { pane_id } => { + let mut w = write_stream; + let list = crate::daemon::ssh::SshManager::global().list_forwards(pane_id); + DaemonMsg::ForwardList(list).encode(&mut w)?; + Ok(()) + } + // `Input` / `Resize` / `Detach` as an opening message are meaningless (no // pane is bound yet); ignore and close. other => { @@ -382,6 +437,20 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { } } +/// Resolve a pane to its live native-SSH connection for a managed-forward request, +/// or a human-readable reason it can't (wrong pane, PTY/compat pane, or a +/// still-authenticating / dropped connection). +fn forward_pane_connection( + registry: &Registry, + pane_id: u64, +) -> Result, String> { + let pane = registry + .get(pane_id) + .ok_or_else(|| format!("no such pane {pane_id}"))?; + pane.ssh_connection() + .ok_or_else(|| "pane is not a ready native-ssh session".to_string()) +} + /// `Attach` path: subscribe the connection to an existing pane (sending the /// recorded `Size` + `Snapshot` + known cwd/prompt), then stream. Splitting /// this out keeps the `Spawn` path (which mustn't re-snapshot before its diff --git a/src/daemon/ssh/forward.rs b/src/daemon/ssh/forward.rs new file mode 100644 index 00000000..ca53fc9e --- /dev/null +++ b/src/daemon/ssh/forward.rs @@ -0,0 +1,865 @@ +//! Port forwarding for native-SSH panes (Workstream 4). +//! +//! Three forward types ride the pane's shared [`SshConnection`] (never a control +//! socket — that path is the frozen ssh-binary ControlMaster mode in +//! `daemon::forward`): +//! +//! - **Local** (FR-F1): a TCP listener on `bind_host:bind_port`; each accepted +//! connection opens a `direct-tcpip` channel to `target_host:target_port` on the +//! connection and [`bridge`]s the two with exact EOF/close propagation. +//! - **Dynamic / SOCKS5** (FR-F1): a local listener speaking a minimal, hand-rolled +//! SOCKS5 (no-auth greeting, CONNECT for IPv4/IPv6/domain; BIND/UDP rejected). +//! Each request opens a `direct-tcpip` to the negotiated target and bridges. +//! - **Remote** (FR-F1): a `tcpip-forward` global request on the connection; +//! incoming `forwarded-tcpip` channels (via the [`super::handler::ClientHandler`]) +//! are matched against [`RemoteForwardTable`] and bridged to a fresh local TCP +//! connection to the registered target. Unmatched channels are rejected. +//! +//! **Registry keying & blast radius.** [`SshForwardRegistry`] keys active forwards +//! by `pane_id` (so the UI lists them per pane) but each forward task holds an +//! `Arc`, so a forward keeps the shared connection alive exactly +//! like `ssh -N`. When a pane dies the daemon calls +//! [`SshForwardRegistry::teardown_pane`], which aborts its listener tasks and +//! cancels its remote bindings; dropping the last `Arc` then tears the connection +//! down. When the *transport* drops, every pane sharing the connection dies as a +//! unit (FR-C2), so every forward attributed to those panes is torn down together. + +use std::collections::HashMap; +use std::io; +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Instant; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::AbortHandle; + +use crate::daemon::protocol::{ + ForwardStatus, LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, + SshForwardKind, SshForwardRule, +}; + +use super::session::SshConnection; + +// --------------------------------------------------------------------------- +// Bidirectional socket<->channel bridge (Tabby brief §5). +// --------------------------------------------------------------------------- + +/// Bridge two duplex streams, propagating EOF and close in both directions: when +/// one side's read half hits EOF, the other side's write half is shut down (a +/// half-close), and once both directions have closed the bridge returns. This +/// mirrors Tabby's `setupSocketChannelEvents` (channel.eof→socket.end, +/// socket.end→channel.eof, close→destroy) so neither a socket nor a russh channel +/// is left half-open. +pub(super) async fn bridge(a: A, b: B) -> io::Result<()> +where + A: AsyncRead + AsyncWrite + Unpin, + B: AsyncRead + AsyncWrite + Unpin, +{ + let (mut ar, mut aw) = tokio::io::split(a); + let (mut br, mut bw) = tokio::io::split(b); + + let a_to_b = async { + tokio::io::copy(&mut ar, &mut bw).await?; + // Source EOF'd: signal it downstream so the peer sees a clean close + // rather than a stall. + bw.shutdown().await + }; + let b_to_a = async { + tokio::io::copy(&mut br, &mut aw).await?; + aw.shutdown().await + }; + + // Run both directions until each has hit EOF (or one errors). `try_join` + // surfaces the first error and drops the other future, which closes its + // half — the connection cannot be left half-open. + tokio::try_join!(a_to_b, b_to_a)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Minimal SOCKS5 (RFC 1928) for Dynamic forwards. +// --------------------------------------------------------------------------- + +/// Negotiate a SOCKS5 CONNECT request on `s`: read the (no-auth) greeting, reply +/// with the no-auth method, read the CONNECT request, and return the requested +/// `(host, port)`. Rejects SOCKS4 (version byte `0x04`), any command other than +/// CONNECT (so BIND/UDP-ASSOCIATE are refused), and unknown address types. The +/// caller opens the upstream channel and then writes the final reply with +/// [`socks5_reply`]. +pub(super) async fn socks5_negotiate(s: &mut S) -> io::Result<(String, u16)> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + // Greeting: VER, NMETHODS, METHODS... + let mut head = [0u8; 2]; + s.read_exact(&mut head).await?; + if head[0] != 0x05 { + // A SOCKS4 client sends 0x04 here; anything but 0x05 is unsupported. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported SOCKS version (only SOCKS5 is accepted)", + )); + } + let nmethods = head[1] as usize; + let mut methods = vec![0u8; nmethods]; + s.read_exact(&mut methods).await?; + if !methods.contains(&0x00) { + // No acceptable methods (0xFF) — we only implement no-auth. + let _ = s.write_all(&[0x05, 0xFF]).await; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 client offered no no-auth method", + )); + } + s.write_all(&[0x05, 0x00]).await?; + + // Request: VER, CMD, RSV, ATYP, ADDR, PORT. + let mut req = [0u8; 4]; + s.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 request had wrong version", + )); + } + if req[1] != 0x01 { + // Only CONNECT (0x01); reject BIND (0x02) / UDP-ASSOCIATE (0x03). + socks5_reply(s, 0x07).await?; // command not supported + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SOCKS5 command not supported (only CONNECT)", + )); + } + let host = match req[3] { + 0x01 => { + let mut a = [0u8; 4]; + s.read_exact(&mut a).await?; + Ipv4Addr::from(a).to_string() + } + 0x04 => { + let mut a = [0u8; 16]; + s.read_exact(&mut a).await?; + std::net::Ipv6Addr::from(a).to_string() + } + 0x03 => { + let mut len = [0u8; 1]; + s.read_exact(&mut len).await?; + let mut name = vec![0u8; len[0] as usize]; + s.read_exact(&mut name).await?; + String::from_utf8(name).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "SOCKS5 domain not UTF-8") + })? + } + other => { + socks5_reply(s, 0x08).await?; // address type not supported + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("SOCKS5 unsupported address type {other}"), + )); + } + }; + let mut port = [0u8; 2]; + s.read_exact(&mut port).await?; + Ok((host, u16::from_be_bytes(port))) +} + +/// Write a SOCKS5 reply with reply code `rep` (0x00 = success), a fixed +/// `0.0.0.0:0` bound address (clients ignore it for CONNECT). +pub(super) async fn socks5_reply(s: &mut S, rep: u8) -> io::Result<()> +where + S: AsyncWrite + Unpin, +{ + s.write_all(&[0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await +} + +// --------------------------------------------------------------------------- +// Remote-forward table (consulted by the connection's Handler). +// --------------------------------------------------------------------------- + +/// The set of `tcpip-forward` bindings registered on one connection, mapping a +/// remote bind address/port to the local target to connect incoming +/// `forwarded-tcpip` channels to. Shared (cheaply cloned `Arc`) between the +/// [`SshConnection`] and its [`super::handler::ClientHandler`]; a reused +/// connection keeps its bindings across panes. +#[derive(Clone, Default)] +pub struct RemoteForwardTable { + inner: Arc>>, +} + +impl RemoteForwardTable { + pub(super) fn register( + &self, + bind_host: &str, + bind_port: u16, + target_host: &str, + target_port: u16, + ) { + self.inner.lock().unwrap().insert( + (bind_host.to_string(), bind_port), + (target_host.to_string(), target_port), + ); + } + + pub(super) fn unregister(&self, bind_host: &str, bind_port: u16) { + self.inner + .lock() + .unwrap() + .remove(&(bind_host.to_string(), bind_port)); + } + + /// Move a binding to a new (server-assigned) port when the client requested + /// port 0. + pub(super) fn rekey(&self, bind_host: &str, from_port: u16, to_port: u16) { + let mut map = self.inner.lock().unwrap(); + if let Some(target) = map.remove(&(bind_host.to_string(), from_port)) { + map.insert((bind_host.to_string(), to_port), target); + } + } + + /// Resolve an incoming `forwarded-tcpip` channel's connected address/port to a + /// local target. Tries the exact `(address, port)` first, then any binding on + /// the same port (the server may report `127.0.0.1` for a `localhost` bind, or + /// `0.0.0.0` for an empty bind address). + pub(super) fn lookup( + &self, + connected_address: &str, + connected_port: u16, + ) -> Option<(String, u16)> { + let map = self.inner.lock().unwrap(); + if let Some(t) = map.get(&(connected_address.to_string(), connected_port)) { + return Some(t.clone()); + } + map.iter() + .find(|((_, p), _)| *p == connected_port) + .map(|(_, t)| t.clone()) + } +} + +// --------------------------------------------------------------------------- +// Managed-forward registry. +// --------------------------------------------------------------------------- + +/// A live forward's teardown handle. +enum ForwardCancel { + /// A Local/Dynamic accept loop; aborting it drops the `TcpListener`. + Task(AbortHandle), + /// A Remote binding to cancel via `cancel_tcpip_forward` on teardown. + Remote { + conn: Weak, + bind_host: String, + bind_port: u16, + }, + /// The forward never came up (bind/request failed); nothing to cancel. + None, +} + +struct ForwardEntry { + id: u64, + kind: SshForwardKind, + bind_host: String, + bind_port: u16, + target_host: String, + target_port: u16, + description: Option, + status: ForwardStatus, + cancel: ForwardCancel, +} + +impl ForwardEntry { + fn to_managed(&self, pane_id: u64) -> ManagedForward { + ManagedForward { + id: self.id, + pane_id, + kind: self.kind, + bind_host: self.bind_host.clone(), + bind_port: self.bind_port, + target_host: self.target_host.clone(), + target_port: self.target_port, + description: self.description.clone(), + status: self.status.clone(), + } + } +} + +/// A native-loopback ("Cmd-click a `localhost:PORT` link") forward on a native-SSH +/// pane (FR-F4). Kept separately from managed forwards so it surfaces in the GUI's +/// existing loopback list alongside the ControlMaster ones, with the same +/// `LoopbackForwardInfo` shape. +struct LoopbackEntry { + local_port: u16, + created_at: Instant, + last_used: Instant, + cancel: AbortHandle, +} + +/// The per-process registry of managed forwards, owned by [`super::SshManager`]. +#[derive(Default)] +pub struct SshForwardRegistry { + panes: Mutex>>, + loopback: Mutex>, + next_id: AtomicU64, +} + +impl SshForwardRegistry { + /// Establish a managed forward for `rule` on `conn`, attribute it to `pane_id`, + /// and return the resulting [`ManagedForward`] (with a resolved bind port and a + /// live status). Failures are reported as `ForwardStatus::Error`, never a hard + /// error — a preconfigured forward that fails must not kill the session. + pub async fn establish( + &self, + pane_id: u64, + conn: Arc, + rule: &SshForwardRule, + ) -> ManagedForward { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (bind_port, status, cancel) = match rule.kind { + SshForwardKind::Local => self.start_local(&conn, rule).await, + SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await, + SshForwardKind::Remote => self.start_remote(&conn, rule).await, + }; + let entry = ForwardEntry { + id, + kind: rule.kind, + bind_host: rule.bind_host.clone(), + bind_port, + target_host: rule.target_host.clone(), + target_port: rule.target_port, + description: rule.description.clone(), + status, + cancel, + }; + let managed = entry.to_managed(pane_id); + self.panes + .lock() + .unwrap() + .entry(pane_id) + .or_default() + .push(entry); + managed + } + + /// The managed forwards attributed to `pane_id`, sorted by id (creation order). + pub fn list(&self, pane_id: u64) -> Vec { + let panes = self.panes.lock().unwrap(); + let mut list: Vec<_> = panes + .get(&pane_id) + .into_iter() + .flatten() + .map(|e| e.to_managed(pane_id)) + .collect(); + list.sort_by_key(|m| m.id); + list + } + + /// Remove one managed forward by id from `pane_id`, tearing down its listener + /// or remote binding. Returns the pane's remaining forwards. + pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec { + let removed = { + let mut panes = self.panes.lock().unwrap(); + if let Some(entries) = panes.get_mut(&pane_id) { + if let Some(pos) = entries.iter().position(|e| e.id == forward_id) { + Some(entries.remove(pos)) + } else { + None + } + } else { + None + } + }; + if let Some(entry) = removed { + Self::cancel_entry(entry).await; + } + self.list(pane_id) + } + + /// Tear down every forward attributed to `pane_id` (called when the pane dies — + /// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are + /// aborted synchronously; remote bindings are cancelled best-effort. + pub async fn teardown_pane(&self, pane_id: u64) { + let entries = self.panes.lock().unwrap().remove(&pane_id); + for entry in entries.into_iter().flatten() { + Self::cancel_entry(entry).await; + } + // Also drop any native-loopback forwards belonging to this pane. + let loopback_ids: Vec = { + let map = self.loopback.lock().unwrap(); + map.keys() + .filter(|k| k.pane_id == pane_id) + .cloned() + .collect() + }; + for id in loopback_ids { + if let Some(entry) = self.loopback.lock().unwrap().remove(&id) { + entry.cancel.abort(); + } + } + } + + async fn cancel_entry(entry: ForwardEntry) { + match entry.cancel { + ForwardCancel::Task(handle) => handle.abort(), + ForwardCancel::Remote { + conn, + bind_host, + bind_port, + } => { + if let Some(conn) = conn.upgrade() { + conn.cancel_remote_forward(&bind_host, bind_port).await; + } + } + ForwardCancel::None => {} + } + } + + async fn start_local( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + let listener = match TcpListener::bind((rule.bind_host.as_str(), rule.bind_port)).await { + Ok(l) => l, + Err(e) => { + return ( + rule.bind_port, + ForwardStatus::Error(format!( + "bind {}:{} failed: {e}", + rule.bind_host, rule.bind_port + )), + ForwardCancel::None, + ); + } + }; + let bound = listener + .local_addr() + .map(|a| a.port()) + .unwrap_or(rule.bind_port); + let conn = conn.clone(); + let target_host = rule.target_host.clone(); + let target_port = rule.target_port; + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + let target_host = target_host.clone(); + tokio::spawn(async move { + match conn.open_direct_tcpip(&target_host, target_port).await { + Ok(channel) => { + let _ = bridge(sock, channel.into_stream()).await; + } + // Remote refused (or the connection died): drop the client + // socket. No secrets in the log. + Err(e) => { + log::info!("local forward to {target_host}:{target_port} rejected: {e}") + } + } + }); + } + }); + ( + bound, + ForwardStatus::Listening, + ForwardCancel::Task(handle.abort_handle()), + ) + } + + async fn start_dynamic( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + let listener = match TcpListener::bind((rule.bind_host.as_str(), rule.bind_port)).await { + Ok(l) => l, + Err(e) => { + return ( + rule.bind_port, + ForwardStatus::Error(format!( + "bind {}:{} failed: {e}", + rule.bind_host, rule.bind_port + )), + ForwardCancel::None, + ); + } + }; + let bound = listener + .local_addr() + .map(|a| a.port()) + .unwrap_or(rule.bind_port); + let conn = conn.clone(); + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + tokio::spawn(async move { + let mut sock = sock; + let (host, port) = match socks5_negotiate(&mut sock).await { + Ok(t) => t, + Err(e) => { + log::info!("dynamic forward: SOCKS5 negotiation failed: {e}"); + return; + } + }; + match conn.open_direct_tcpip(&host, port).await { + Ok(channel) => { + if socks5_reply(&mut sock, 0x00).await.is_err() { + return; + } + let _ = bridge(sock, channel.into_stream()).await; + } + Err(e) => { + // 0x05 = connection refused by destination host. + let _ = socks5_reply(&mut sock, 0x05).await; + log::info!("dynamic forward to {host}:{port} rejected: {e}"); + } + } + }); + } + }); + ( + bound, + ForwardStatus::Listening, + ForwardCancel::Task(handle.abort_handle()), + ) + } + + async fn start_remote( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, ForwardStatus, ForwardCancel) { + match conn + .add_remote_forward( + &rule.bind_host, + rule.bind_port, + &rule.target_host, + rule.target_port, + ) + .await + { + Ok(bound) => ( + bound, + ForwardStatus::Listening, + ForwardCancel::Remote { + conn: Arc::downgrade(conn), + bind_host: rule.bind_host.clone(), + bind_port: bound, + }, + ), + Err(e) => ( + rule.bind_port, + ForwardStatus::Error(format!("remote forward request denied: {e}")), + ForwardCancel::None, + ), + } + } + + // ---- Native loopback (FR-F4) -------------------------------------------- + + /// Ensure a native-SSH loopback forward `127.0.0.1: → host:port` + /// exists for `pane_id`, reusing an existing one for the same target. Mirrors + /// the ControlMaster `ForwardManager::ensure` reply shape so the GUI's + /// Cmd-click flow is unchanged. + pub async fn ensure_loopback( + &self, + pane_id: u64, + conn: Arc, + target: &str, + remote_host: &str, + remote_port: u16, + ) -> io::Result { + let id = LoopbackForwardId { + pane_id, + target: target.to_string(), + remote_host: remote_host.to_string(), + remote_port, + }; + if let Some(entry) = self.loopback.lock().unwrap().get_mut(&id) { + entry.last_used = Instant::now(); + return Ok(LoopbackForward { + local_port: entry.local_port, + }); + } + // Bind an ephemeral loopback listener and forward it to the remote target. + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local_port = listener.local_addr()?.port(); + let remote_host_owned = remote_host.to_string(); + let handle = tokio::spawn(async move { + loop { + let Ok((sock, _peer)) = listener.accept().await else { + break; + }; + if !conn.is_alive() { + break; + } + let conn = conn.clone(); + let remote_host = remote_host_owned.clone(); + tokio::spawn(async move { + match conn.open_direct_tcpip(&remote_host, remote_port).await { + Ok(channel) => { + let _ = bridge(sock, channel.into_stream()).await; + } + Err(e) => log::info!( + "loopback forward to {remote_host}:{remote_port} rejected: {e}" + ), + } + }); + } + }); + self.loopback.lock().unwrap().insert( + id, + LoopbackEntry { + local_port, + created_at: Instant::now(), + last_used: Instant::now(), + cancel: handle.abort_handle(), + }, + ); + Ok(LoopbackForward { local_port }) + } + + /// The active native-loopback forwards, in the `LoopbackForwardInfo` shape the + /// GUI's loopback panel already renders. + pub fn list_loopback(&self) -> Vec { + let map = self.loopback.lock().unwrap(); + let mut list: Vec<_> = map + .iter() + .map(|(id, entry)| LoopbackForwardInfo { + id: id.clone(), + local_port: entry.local_port, + age_secs: entry.created_at.elapsed().as_secs(), + idle_secs: entry.last_used.elapsed().as_secs(), + }) + .collect(); + list.sort_by(|a, b| { + a.id.target + .cmp(&b.id.target) + .then_with(|| a.id.remote_host.cmp(&b.id.remote_host)) + .then_with(|| a.id.remote_port.cmp(&b.id.remote_port)) + .then_with(|| a.local_port.cmp(&b.local_port)) + }); + list + } + + /// Close one native-loopback forward. Returns whether it existed. + pub fn close_loopback(&self, id: &LoopbackForwardId) -> bool { + if let Some(entry) = self.loopback.lock().unwrap().remove(id) { + entry.cancel.abort(); + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// A SOCKS4 client (version byte `0x04`) is rejected outright. + #[tokio::test] + async fn socks5_rejects_v4() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x04, 0x01]).await.unwrap(); + let err = socks5_negotiate(&mut server).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + /// A well-formed v5 CONNECT to an IPv4 address is parsed and the method reply is + /// the no-auth selection. + #[tokio::test] + async fn socks5_v5_connect_ipv4() { + let (mut client, mut server) = tokio::io::duplex(64); + // Greeting (1 method: no-auth) + CONNECT to 1.2.3.4:80. + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + client + .write_all(&[0x05, 0x01, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50]) + .await + .unwrap(); + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "1.2.3.4"); + assert_eq!(port, 80); + // Method-selection reply is VER=5, METHOD=0 (no auth). + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 CONNECT with a domain-name address (ATYP=3). + #[tokio::test] + async fn socks5_v5_connect_domain() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let host = b"example.com"; + let mut req = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8]; + req.extend_from_slice(host); + req.extend_from_slice(&443u16.to_be_bytes()); + client.write_all(&req).await.unwrap(); + // Negotiate before draining the reply: on a single-threaded test runtime + // the writer must run first, or the reply read would deadlock. + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 CONNECT with an IPv6 address (ATYP=4). + #[tokio::test] + async fn socks5_v5_connect_ipv6() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + let mut req = vec![0x05, 0x01, 0x00, 0x04]; + req.extend_from_slice(&std::net::Ipv6Addr::LOCALHOST.octets()); + req.extend_from_slice(&22u16.to_be_bytes()); + client.write_all(&req).await.unwrap(); + // Negotiate before draining the reply (see the domain test). + let (host, port) = socks5_negotiate(&mut server).await.unwrap(); + assert_eq!(host, "::1"); + assert_eq!(port, 22); + let mut reply = [0u8; 2]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x05, 0x00]); + } + + /// A v5 BIND command (0x02) is rejected with a "command not supported" reply. + #[tokio::test] + async fn socks5_rejects_bind_command() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); + client + .write_all(&[0x05, 0x02, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50]) + .await + .unwrap(); + let err = socks5_negotiate(&mut server).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + // Method reply then a 0x07 (command not supported) reply. + let mut method = [0u8; 2]; + client.read_exact(&mut method).await.unwrap(); + assert_eq!(method, [0x05, 0x00]); + let mut rep = [0u8; 10]; + client.read_exact(&mut rep).await.unwrap(); + assert_eq!(rep[1], 0x07); + } + + /// The bridge forwards bytes A→B and propagates the A-side EOF as a clean close + /// on the B side (and streams a reply back B→A). + #[tokio::test] + async fn bridge_propagates_data_and_eof_both_directions() { + // client_a <-> a ...bridge... b <-> server_b + let (mut client_a, a) = tokio::io::duplex(64); + let (b, mut server_b) = tokio::io::duplex(64); + let bridged = tokio::spawn(async move { bridge(a, b).await }); + + // A→B data, then close A's write half. + client_a.write_all(b"ping").await.unwrap(); + client_a.shutdown().await.unwrap(); + + let mut got = Vec::new(); + server_b.read_to_end(&mut got).await.unwrap(); + assert_eq!( + got, b"ping", + "A→B data delivered and A-side EOF closed B read" + ); + + // B→A reply after the far side EOF'd — must still flow, then close. + server_b.write_all(b"pong").await.unwrap(); + server_b.shutdown().await.unwrap(); + let mut back = Vec::new(); + client_a.read_to_end(&mut back).await.unwrap(); + assert_eq!( + back, b"pong", + "B→A reply delivered and B-side EOF closed A read" + ); + + bridged.await.unwrap().unwrap(); + } + + /// The remote-forward table resolves exact matches and falls back to any binding + /// on the same port (server may report a different bind address). + #[test] + fn remote_forward_table_lookup() { + let table = RemoteForwardTable::default(); + table.register("localhost", 9000, "127.0.0.1", 3000); + assert_eq!( + table.lookup("localhost", 9000), + Some(("127.0.0.1".to_string(), 3000)) + ); + // The server reported 127.0.0.1 for a localhost bind → port fallback. + assert_eq!( + table.lookup("127.0.0.1", 9000), + Some(("127.0.0.1".to_string(), 3000)) + ); + assert_eq!(table.lookup("localhost", 9999), None); + table.unregister("localhost", 9000); + assert_eq!(table.lookup("localhost", 9000), None); + } + + /// The registry's add/list/remove/teardown bookkeeping, independent of a live + /// connection (entries are inserted directly, bypassing `establish` which needs + /// an authenticated `SshConnection`). Aborting the cancel task on remove/teardown + /// is what a real listener teardown does. + #[tokio::test] + async fn registry_add_list_remove_teardown_bookkeeping() { + let reg = SshForwardRegistry::default(); + let make = |id: u64, port: u16| { + let task = tokio::spawn(async { std::future::pending::<()>().await }); + ForwardEntry { + id, + kind: SshForwardKind::Local, + bind_host: "127.0.0.1".into(), + bind_port: port, + target_host: "h".into(), + target_port: 80, + description: None, + status: ForwardStatus::Listening, + cancel: ForwardCancel::Task(task.abort_handle()), + } + }; + { + let mut panes = reg.panes.lock().unwrap(); + let entries = panes.entry(7).or_default(); + entries.push(make(0, 8000)); + entries.push(make(1, 8001)); + } + // list is per-pane and sorted by id. + let list = reg.list(7); + assert_eq!(list.iter().map(|m| m.id).collect::>(), vec![0, 1]); + assert!(reg.list(99).is_empty(), "other panes see nothing"); + + // remove drops just the one forward and returns the remainder. + let remaining = reg.remove(7, 0).await; + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, 1); + + // teardown clears the pane entirely (blast-radius on death). + reg.teardown_pane(7).await; + assert!(reg.list(7).is_empty()); + } + + /// `rekey` moves a binding to the server-assigned port (bind_port 0 case). + #[test] + fn remote_forward_table_rekey() { + let table = RemoteForwardTable::default(); + table.register("", 0, "127.0.0.1", 3000); + table.rekey("", 0, 40000); + assert_eq!( + table.lookup("", 40000), + Some(("127.0.0.1".to_string(), 3000)) + ); + assert_eq!(table.lookup("", 0), None); + } +} diff --git a/src/daemon/ssh/handler.rs b/src/daemon/ssh/handler.rs index 4735d9a3..76dd906f 100644 --- a/src/daemon/ssh/handler.rs +++ b/src/daemon/ssh/handler.rs @@ -1,18 +1,35 @@ -//! The russh client [`Handler`]: host-key verification and auth banners. +//! The russh client [`Handler`]: host-key verification, auth banners, and +//! incoming forwarded channels. //! //! russh invokes `check_server_key` during the handshake (once per connection — //! reused connections never re-run it) and `auth_banner` if the server sends one. //! Both route through the [`PromptBroker`] so the *GUI* makes the trust decision //! and sees the banner; the daemon owns the `known_hosts` storage per PRD §3.4. +//! +//! `server_channel_open_forwarded_tcpip` implements the Remote-forward +//! (`tcpip-forward`) receive side (WS4): incoming channels are matched against the +//! connection's [`RemoteForwardTable`] and bridged to a local socket. +//! +//! **X11 seam (P1, FR-X2 — deferred).** WS2 carries `NativeSshSpec.x11` but never +//! requests `x11-req` on the shell channel, so no X11 channels arrive and the +//! default `server_channel_open_x11` (auto-reject on drop) is correct. Wiring X11 +//! would add: `channel.request_x11(..)` at shell start (with a MIT-MAGIC-COOKIE-1 +//! cookie), a `server_channel_open_x11` override here that resolves the local +//! display (`$DISPLAY` → `/tmp/.X11-unix/X` unix socket or `localhost:6000+n`), +//! and `forward::bridge` to that socket — mirroring the forwarded-tcpip path below. +//! Left unimplemented deliberately (macOS needs XQuartz; low priority). use std::sync::Arc; -use russh::client::Session; +use russh::Channel; +use russh::client::{ChannelOpenHandle, Msg, Session}; use russh::keys::PublicKey; +use tokio::net::TcpStream; use crate::daemon::protocol::{AuthPromptKind, AuthResponse}; use super::broker::PromptBroker; +use super::forward::{self, RemoteForwardTable}; use super::known_hosts::{self, HostKeyStatus}; pub struct ClientHandler { @@ -21,6 +38,10 @@ pub struct ClientHandler { pub verify_host_keys: bool, pub skip_banner: bool, pub broker: Arc, + /// The connection's Remote-forward bindings (WS4). Shared with its + /// [`super::session::SshConnection`]; incoming `forwarded-tcpip` channels are + /// matched against it and bridged to the registered local target. + pub remote_forwards: RemoteForwardTable, } impl ClientHandler { @@ -50,7 +71,10 @@ impl ClientHandler { impl russh::client::Handler for ClientHandler { type Error = russh::Error; - async fn check_server_key(&mut self, server_public_key: &PublicKey) -> Result { + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { // A per-profile / global opt-out (FR-S4): trust unconditionally. if !self.verify_host_keys { return Ok(true); @@ -93,10 +117,54 @@ impl russh::client::Handler for ClientHandler { } } - async fn auth_banner(&mut self, banner: &str, _session: &mut Session) -> Result<(), Self::Error> { + async fn auth_banner( + &mut self, + banner: &str, + _session: &mut Session, + ) -> Result<(), Self::Error> { if !self.skip_banner && !banner.is_empty() { self.broker.banner(banner.to_string()); } Ok(()) } + + /// An incoming connection on a Remote (`tcpip-forward`) binding. Match it + /// against this connection's registered forwards; on a hit, accept the channel + /// and bridge it to a fresh local TCP connection to the target. An unmatched + /// channel is rejected (dropping `reply` rejects) — a remote forward we don't + /// own must not be tunneled anywhere. + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + connected_address: &str, + connected_port: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + let Some((target_host, target_port)) = self + .remote_forwards + .lookup(connected_address, connected_port as u16) + else { + log::info!( + "rejecting unmatched forwarded-tcpip channel on {connected_address}:{connected_port}" + ); + // Dropping `reply` rejects the channel. + return Ok(()); + }; + reply.accept().await; + let stream = channel.into_stream(); + tokio::spawn(async move { + match TcpStream::connect((target_host.as_str(), target_port)).await { + Ok(sock) => { + let _ = forward::bridge(stream, sock).await; + } + Err(e) => log::info!( + "remote forward: local connect to {target_host}:{target_port} failed: {e}" + ), + } + }); + Ok(()) + } } diff --git a/src/daemon/ssh/mod.rs b/src/daemon/ssh/mod.rs index eef414d7..dd885051 100644 --- a/src/daemon/ssh/mod.rs +++ b/src/daemon/ssh/mod.rs @@ -16,6 +16,7 @@ //! `DaemonPane::ssh_connection` (in `daemon::pane`) exposes a pane's connection. pub mod broker; +pub mod forward; pub mod known_hosts; pub mod session; @@ -24,6 +25,7 @@ mod connect; mod handler; pub use broker::PromptBroker; +pub use forward::SshForwardRegistry; pub use session::{ChannelCmd, SharedConnection, SshConnection, SshSessionHandle}; use std::collections::HashMap; @@ -34,8 +36,12 @@ use std::time::Duration; use russh::Pty; -use crate::daemon::protocol::{NativeSshSpec, SshPhase, WinSize}; +use crate::daemon::protocol::{ + LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec, + SshForwardRule, SshPhase, WinSize, +}; +use forward::RemoteForwardTable; use handler::ClientHandler; use session::drive_channel; @@ -74,6 +80,9 @@ type ConnSlot = Arc>>; pub struct SshManager { runtime: tokio::runtime::Runtime, conns: Mutex>, + /// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback), + /// driven on this manager's runtime. + forwards: SshForwardRegistry, } impl SshManager { @@ -90,10 +99,78 @@ impl SshManager { SshManager { runtime, conns: Mutex::new(HashMap::new()), + forwards: SshForwardRegistry::default(), } }) } + // ---- Synchronous forward API for the (std-thread) daemon server ---------- + // + // The server dispatch runs on plain std threads; these block on the runtime + // for the async establishment/teardown while returning results synchronously. + + /// Establish a managed forward on `conn` for `pane_id`; returns the pane's + /// forwards after the add. + pub fn add_forward( + &self, + pane_id: u64, + conn: Arc, + rule: &SshForwardRule, + ) -> Vec { + self.runtime.block_on(async { + self.forwards.establish(pane_id, conn, rule).await; + self.forwards.list(pane_id) + }) + } + + /// Remove a managed forward by id; returns the pane's remaining forwards. + pub fn remove_forward(&self, pane_id: u64, forward_id: u64) -> Vec { + self.runtime + .block_on(self.forwards.remove(pane_id, forward_id)) + } + + /// List a pane's managed forwards. + pub fn list_forwards(&self, pane_id: u64) -> Vec { + self.forwards.list(pane_id) + } + + /// Tear down every forward attributed to `pane_id` (pane death / blast radius). + /// Detached on the runtime so a pane's `Drop` (which runs on a connection + /// thread) never blocks on a remote `cancel_tcpip_forward` round-trip. + pub fn teardown_pane_forwards(&'static self, pane_id: u64) { + self.runtime.spawn(async move { + self.forwards.teardown_pane(pane_id).await; + }); + } + + /// Ensure a native-SSH loopback forward for a Cmd-clicked `localhost` URL (FR-F4). + pub fn ensure_loopback_forward( + &self, + pane_id: u64, + conn: Arc, + target: &str, + remote_host: &str, + remote_port: u16, + ) -> std::io::Result { + self.runtime.block_on(self.forwards.ensure_loopback( + pane_id, + conn, + target, + remote_host, + remote_port, + )) + } + + /// The active native-SSH loopback forwards, in the GUI's loopback list shape. + pub fn list_loopback_forwards(&self) -> Vec { + self.forwards.list_loopback() + } + + /// Close one native-SSH loopback forward. + pub fn close_loopback_forward(&self, id: &LoopbackForwardId) -> bool { + self.forwards.close_loopback(id) + } + /// Kick off a native-SSH shell for a pane. Returns immediately; the connect → /// auth → shell sequence runs on the runtime and drives the pane through the /// provided bridge ends. All progress/prompt frames go via `broker`. @@ -104,6 +181,7 @@ impl SshManager { /// to the rest of the daemon exactly like a shell that exited. pub fn spawn_native_session( &'static self, + pane_id: u64, spec: Box, size: WinSize, broker: Arc, @@ -113,7 +191,15 @@ impl SshManager { ) { self.runtime.spawn(async move { if let Err(reason) = self - .run_session(&spec, size, &broker, data_tx.clone(), cmd_rx, &conn_slot) + .run_session( + pane_id, + &spec, + size, + &broker, + data_tx.clone(), + cmd_rx, + &conn_slot, + ) .await { broker.status(SshPhase::Failed { @@ -130,6 +216,7 @@ impl SshManager { async fn run_session( &'static self, + pane_id: u64, spec: &NativeSshSpec, size: WinSize, broker: &Arc, @@ -155,6 +242,13 @@ impl SshManager { broker.status(SshPhase::Connected); + // Establish the profile's preconfigured forwards (FR-F2) now that the + // connection is authenticated. Failures are non-fatal — each surfaces as a + // `ForwardStatus::Error` on the forward row, never a killed session. + for rule in &spec.forwards { + self.forwards.establish(pane_id, conn.clone(), rule).await; + } + // Open the shell channel on the (possibly shared) connection. let channel = conn .open_session_channel() @@ -238,12 +332,16 @@ impl SshManager { .filter(|v| *v > 0) .map(|v| Duration::from_secs(u64::from(v))) .unwrap_or(DEFAULT_CONNECT_TIMEOUT); + // The connection's Remote-forward table, shared with its handler so + // incoming `forwarded-tcpip` channels resolve to a local target (WS4). + let remote_forwards = RemoteForwardTable::default(); let handler = ClientHandler { host: spec.host.clone(), port: spec.port, verify_host_keys: spec.verify_host_keys, skip_banner: spec.skip_banner, broker: broker.clone(), + remote_forwards: remote_forwards.clone(), }; let handshake = async { let transport = connect::build_transport(spec, jump).await?; @@ -263,7 +361,7 @@ impl SshManager { .await .map_err(anyhow::Error::msg)?; - let conn = SshConnection::new(handle, key); + let conn = SshConnection::new(handle, key, remote_forwards); *guard = Arc::downgrade(&conn); Ok(conn) }) diff --git a/src/daemon/ssh/session.rs b/src/daemon/ssh/session.rs index dadd5c91..49401d14 100644 --- a/src/daemon/ssh/session.rs +++ b/src/daemon/ssh/session.rs @@ -32,6 +32,7 @@ use russh::{Channel, ChannelMsg}; use crate::daemon::protocol::WinSize; use super::ConnectionKey; +use super::forward::RemoteForwardTable; /// Bounded depth (in messages) of the driver→reader data channel. Each message is /// one russh data chunk (≤ the channel's max packet size, ~32 KiB), so this caps @@ -258,6 +259,11 @@ pub struct SshConnection { /// as the stable identity WS4/WS5 will match against. #[allow(dead_code)] key: ConnectionKey, + /// The connection's active `tcpip-forward` bindings (WS4 Remote forwards). + /// Shared with this connection's [`super::handler::ClientHandler`] so incoming + /// `forwarded-tcpip` channels resolve to a local target. Empty for a connection + /// with no remote forwards. + remote_forwards: RemoteForwardTable, alive: AtomicBool, } @@ -265,10 +271,12 @@ impl SshConnection { pub(super) fn new( handle: russh::client::Handle, key: ConnectionKey, + remote_forwards: RemoteForwardTable, ) -> Arc { Arc::new(Self { handle: tokio::sync::Mutex::new(handle), key, + remote_forwards, alive: AtomicBool::new(true), }) } @@ -307,9 +315,65 @@ impl SshConnection { self.handle .lock() .await - .channel_open_direct_tcpip(host.to_string(), u32::from(port), "127.0.0.1".to_string(), 0) + .channel_open_direct_tcpip( + host.to_string(), + u32::from(port), + "127.0.0.1".to_string(), + 0, + ) .await } + + /// Request a `tcpip-forward` binding on `bind_host:bind_port`, routing incoming + /// `forwarded-tcpip` channels to `target_host:target_port` (WS4 Remote forward). + /// Registers the target *before* the request so an eager server channel finds + /// it. Returns the resolved bind port (the server assigns one when `bind_port` + /// is 0). On failure the registration is rolled back. + pub async fn add_remote_forward( + &self, + bind_host: &str, + bind_port: u16, + target_host: &str, + target_port: u16, + ) -> Result { + self.remote_forwards + .register(bind_host, bind_port, target_host, target_port); + let requested = self + .handle + .lock() + .await + .tcpip_forward(bind_host.to_string(), u32::from(bind_port)) + .await; + match requested { + Ok(assigned) => { + let real = if bind_port == 0 { + assigned as u16 + } else { + bind_port + }; + if real != bind_port { + self.remote_forwards.rekey(bind_host, bind_port, real); + } + Ok(real) + } + Err(e) => { + self.remote_forwards.unregister(bind_host, bind_port); + Err(format!("{e}")) + } + } + } + + /// Cancel a previously requested `tcpip-forward` binding (best effort) and drop + /// its target registration. + pub async fn cancel_remote_forward(&self, bind_host: &str, bind_port: u16) { + self.remote_forwards.unregister(bind_host, bind_port); + let _ = self + .handle + .lock() + .await + .cancel_tcpip_forward(bind_host.to_string(), u32::from(bind_port)) + .await; + } } #[cfg(test)] diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 51734fb4..39d33b1b 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -36,7 +36,7 @@ use alacritty_terminal::vte::ansi; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ ClientMsg, DaemonMsg, LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, - LoopbackForwardRequest, RemoteContext, ShellSpec, WinSize, + LoopbackForwardRequest, ManagedForward, RemoteContext, ShellSpec, SshForwardRule, WinSize, }; use crate::daemon::transport::{self, Stream}; @@ -862,6 +862,56 @@ impl RemoteTerminal { } query(id).unwrap_or_default() } + + /// Establish a managed forward (Local/Remote/Dynamic) on a native-SSH pane over + /// a short-lived control connection; returns the pane's forwards after the add. + /// One-shot, modeled on `list_loopback_forwards`. + pub fn add_forward(pane_id: u64, rule: SshForwardRule) -> Vec { + fn query(pane_id: u64, rule: SshForwardRule) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::AddForward { pane_id, rule }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)), + other => Err(anyhow::anyhow!("unexpected reply to AddForward: {other:?}")), + } + } + query(pane_id, rule).unwrap_or_default() + } + + /// Tear down one managed forward by id; returns the pane's remaining forwards. + pub fn remove_forward(pane_id: u64, forward_id: u64) -> Vec { + fn query(pane_id: u64, forward_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::RemoveForward { + pane_id, + forward_id, + } + .encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to RemoveForward: {other:?}" + )), + } + } + query(pane_id, forward_id).unwrap_or_default() + } + + /// List a native-SSH pane's managed forwards. + pub fn list_forwards(pane_id: u64) -> Vec { + fn query(pane_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::ListForwards { pane_id }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to ListForwards: {other:?}" + )), + } + } + query(pane_id).unwrap_or_default() + } } fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 08470989..8a278f84 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -3208,10 +3208,13 @@ impl TerminalView { fn can_forward_loopback(&self, cx: &mut Context) -> bool { cx.global::().ssh_loopback_forward - && self - .terminal - .remote_context() - .is_some_and(|remote| remote.control_path.is_some()) + && self.terminal.remote_context().is_some_and(|remote| { + // A compat-mode ssh pane forwards through its ControlMaster socket; a + // native russh pane (WS4, FR-F4) forwards through its in-memory + // connection — neither of which the other has, so accept either. + remote.control_path.is_some() + || remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh + }) } /// Update the remembered hovered link for the screen cell `(col, row)` and diff --git a/src/ui/app.rs b/src/ui/app.rs index ef7db3ca..dbbfa0ea 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -123,6 +123,15 @@ pub(crate) struct LoopbackForwardPanelState { pub(crate) host_input: Entity, pub(crate) port_input: Entity, pub(crate) editing: Option, + /// Managed forwards (Local/Remote/Dynamic) for the open native-SSH pane (WS4). + pub(crate) managed: Vec, + /// Add-forward form state (native-SSH panes only). + pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind, + pub(crate) mf_bind_host: Entity, + pub(crate) mf_bind_port: Entity, + pub(crate) mf_target_host: Entity, + pub(crate) mf_target_port: Entity, + pub(crate) mf_description: Entity, } pub struct Tty7App { @@ -256,6 +265,12 @@ impl Tty7App { .placeholder("3000") .default_value("") }); + // Managed-forward add-form inputs (native-SSH panes). + let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1")); + let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080")); + let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); + let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80")); + let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description")); let sidebar_width = cx.global::().sidebar_width; // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the // `Config` global on every `config.json` change, which fires this. Theme @@ -339,6 +354,13 @@ impl Tty7App { host_input: loopback_host_input, port_input: loopback_port_input, editing: None, + managed: Vec::new(), + mf_kind: crate::daemon::protocol::SshForwardKind::Local, + mf_bind_host, + mf_bind_port, + mf_target_host, + mf_target_port, + mf_description, }, sidebar_width: Rc::new(Cell::new(sidebar_width)), sidebar_dragging: Rc::new(Cell::new(false)), @@ -852,6 +874,114 @@ impl Tty7App { cx.notify(); } + /// Refresh the managed (Local/Remote/Dynamic) forwards for `pane_id` (WS4). + pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context) { + self.loopback_panel.managed = crate::terminal::RemoteTerminal::list_forwards(pane_id); + cx.notify(); + } + + /// Pick the kind for the add-forward form (native-SSH panes). + pub(crate) fn set_managed_forward_kind( + &mut self, + kind: crate::daemon::protocol::SshForwardKind, + cx: &mut Context, + ) { + self.loopback_panel.mf_kind = kind; + cx.notify(); + } + + /// Establish the add-form's managed forward on `pane_id`'s connection, then + /// clear the form. A blank/invalid bind port is ignored; Dynamic forwards need + /// no target. + pub(crate) fn add_managed_forward( + &mut self, + pane_id: u64, + window: &mut Window, + cx: &mut Context, + ) { + use crate::daemon::protocol::{SshForwardKind, SshForwardRule}; + let kind = self.loopback_panel.mf_kind; + let bind_host = self + .loopback_panel + .mf_bind_host + .read(cx) + .value() + .trim() + .to_string(); + let bind_host = if bind_host.is_empty() { + "127.0.0.1".to_string() + } else { + bind_host + }; + let Ok(bind_port) = self + .loopback_panel + .mf_bind_port + .read(cx) + .value() + .trim() + .parse::() + else { + return; + }; + let target_host = self + .loopback_panel + .mf_target_host + .read(cx) + .value() + .trim() + .to_string(); + let target_port = self + .loopback_panel + .mf_target_port + .read(cx) + .value() + .trim() + .parse::() + .unwrap_or(0); + // Local/Remote require a target; Dynamic (SOCKS) does not. + if kind != SshForwardKind::Dynamic && (target_host.is_empty() || target_port == 0) { + return; + } + let description = self + .loopback_panel + .mf_description + .read(cx) + .value() + .trim() + .to_string(); + let rule = SshForwardRule { + kind, + bind_host, + bind_port, + target_host, + target_port, + description: (!description.is_empty()).then_some(description), + }; + self.loopback_panel.managed = crate::terminal::RemoteTerminal::add_forward(pane_id, rule); + // Reset the value-carrying fields; keep bind host default. + for input in [ + &self.loopback_panel.mf_bind_port, + &self.loopback_panel.mf_target_host, + &self.loopback_panel.mf_target_port, + &self.loopback_panel.mf_description, + ] { + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + cx.notify(); + } + + /// Tear down one managed forward by id (native-SSH panes). + pub(crate) fn remove_managed_forward( + &mut self, + pane_id: u64, + forward_id: u64, + cx: &mut Context, + ) { + self.loopback_panel.managed = + crate::terminal::RemoteTerminal::remove_forward(pane_id, forward_id); + cx.notify(); + } + pub(crate) fn toggle_loopback_forward_panel(&mut self, pane_id: u64, cx: &mut Context) { let should_open = self.loopback_panel.open_pane_id != Some(pane_id); if should_open { @@ -865,6 +995,7 @@ impl Tty7App { self.loopback_panel.editing = None; } self.refresh_loopback_forwards(cx); + self.refresh_managed_forwards(pane_id, cx); } else { self.loopback_panel.open_pane_id = None; self.loopback_panel.editing = None; diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 806d24e1..30a538e0 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -9,7 +9,9 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; -use crate::daemon::protocol::{LoopbackForwardInfo, RemoteContext}; +use crate::daemon::protocol::{ + ForwardStatus, LoopbackForwardInfo, ManagedForward, RemoteContext, RemoteKind, SshForwardKind, +}; use crate::ui::app::Tty7App; impl Tty7App { @@ -21,7 +23,13 @@ impl Tty7App { ) -> AnyElement { let foreground = cx.theme().foreground; let pane_forwards = self.loopback_forwards_for_pane(pane_id); - let active_count = pane_forwards.len(); + let is_native = remote.kind == RemoteKind::NativeSsh; + let managed_count = if is_native { + self.loopback_panel.managed.len() + } else { + 0 + }; + let active_count = pane_forwards.len() + managed_count; let panel_open = self.loopback_panel.open_pane_id == Some(pane_id); let label = if active_count == 0 { "Ports".to_string() @@ -82,12 +90,14 @@ impl Tty7App { .small() .on_click(cx.listener(|this, _, _w, cx| this.close_loopback_forward_panel(cx))); - let body = if forwards.is_empty() { + let is_native = remote.kind == RemoteKind::NativeSsh; + + let loopback_body = if forwards.is_empty() { v_flex().child( div() .text_sm() .text_color(muted_foreground) - .child("No active forwards for this host."), + .child("No loopback forwards for this host."), ) } else { let mut list = v_flex().gap_2(); @@ -99,7 +109,7 @@ impl Tty7App { v_flex() .w(px(460.)) - .max_h(px(420.)) + .max_h(px(560.)) .gap_3() .p_3() .overflow_hidden() @@ -132,10 +142,219 @@ impl Tty7App { ) .child(h_flex().gap_2().child(refresh).child(close)), ) - .child(self.render_loopback_forward_form(pane_id, cx)) + // Managed L/R/D forwards come first for native panes; the loopback + // one-click list stays below and is shown for both pane kinds. + .when(is_native, |this| { + this.child(self.render_managed_forwards_section(pane_id, cx)) + }) + .child( + v_flex() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Loopback (localhost links)"), + ) + .child(self.render_loopback_forward_form(pane_id, cx)) + .child(loopback_body), + ) + } + + /// The managed-forward (Local/Remote/Dynamic) section shown for native-SSH + /// panes: an add form with a kind selector and the live forward rows (WS4). + fn render_managed_forwards_section(&self, pane_id: u64, cx: &mut Context) -> Div { + let foreground = cx.theme().foreground; + let muted_foreground = cx.theme().muted_foreground; + let managed: Vec = self + .loopback_panel + .managed + .iter() + .filter(|m| m.pane_id == pane_id) + .cloned() + .collect(); + + let body = if managed.is_empty() { + v_flex().child( + div() + .text_sm() + .text_color(muted_foreground) + .child("No managed forwards."), + ) + } else { + let mut list = v_flex().gap_2(); + for forward in &managed { + list = list.child(self.render_managed_forward_row(forward, cx)); + } + list + }; + + v_flex() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("Managed forwards"), + ) + .child(self.render_managed_forward_form(pane_id, cx)) .child(body) } + fn render_managed_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let kind = self.loopback_panel.mf_kind; + let selected = match kind { + SshForwardKind::Local => 0, + SshForwardKind::Remote => 1, + SshForwardKind::Dynamic => 2, + }; + // Dynamic (SOCKS) forwards have no fixed target — grey the target inputs. + let needs_target = kind != SshForwardKind::Dynamic; + + let bind_host = div() + .w(px(150.)) + .child(Input::new(&self.loopback_panel.mf_bind_host).small()); + let bind_port = div() + .w(px(80.)) + .child(Input::new(&self.loopback_panel.mf_bind_port).small()); + let target_host = div() + .w(px(150.)) + .child(Input::new(&self.loopback_panel.mf_target_host).small()); + let target_port = div() + .w(px(80.)) + .child(Input::new(&self.loopback_panel.mf_target_port).small()); + let description = div() + .w_full() + .child(Input::new(&self.loopback_panel.mf_description).small()); + + v_flex() + .gap_2() + .py_1() + .child(self.segmented( + "ssh-managed-forward-kind", + &["Local", "Remote", "Dynamic"], + selected, + cx, + move |this, ix, _window, cx| { + let kind = match ix { + 1 => SshForwardKind::Remote, + 2 => SshForwardKind::Dynamic, + _ => SshForwardKind::Local, + }; + this.set_managed_forward_kind(kind, cx); + }, + )) + .child( + h_flex() + .items_center() + .gap_1() + .child(div().w(px(48.)).text_xs().text_color(muted).child("bind")) + .child(bind_host) + .child(div().text_sm().text_color(muted).child(":")) + .child(bind_port), + ) + .child( + h_flex() + .items_center() + .gap_1() + .opacity(if needs_target { 1.0 } else { 0.4 }) + .child( + div() + .w(px(48.)) + .text_xs() + .text_color(muted) + .child(if needs_target { "target" } else { "SOCKS" }), + ) + .child(target_host) + .child(div().text_sm().text_color(muted).child(":")) + .child(target_port), + ) + .child( + h_flex().items_center().gap_2().child(description).child( + Button::new(("ssh-managed-forward-add", pane_id)) + .label("Add") + .small() + .primary() + .on_click(cx.listener(move |this, _, window, cx| { + this.add_managed_forward(pane_id, window, cx) + })), + ), + ) + } + + fn render_managed_forward_row(&self, forward: &ManagedForward, cx: &mut Context) -> Div { + let theme = cx.theme(); + let (badge, badge_color) = match forward.kind { + SshForwardKind::Local => ("L", theme.info), + SshForwardKind::Remote => ("R", theme.warning), + SshForwardKind::Dynamic => ("D", theme.success), + }; + let bind = format!("{}:{}", forward.bind_host, forward.bind_port); + let flow = if forward.kind == SshForwardKind::Dynamic { + format!("{bind} (SOCKS)") + } else { + format!("{bind} -> {}:{}", forward.target_host, forward.target_port) + }; + let (status_text, status_color) = match &forward.status { + ForwardStatus::Listening => ("listening".to_string(), theme.success), + ForwardStatus::Error(msg) => (format!("error: {msg}"), theme.danger), + }; + let pane_id = forward.pane_id; + let forward_id = forward.id; + + h_flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .border_1() + .border_color(theme.border) + .rounded_md() + .child( + div() + .flex_none() + .w(px(20.)) + .h(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .bg(badge_color.opacity(0.15)) + .text_xs() + .font_weight(FontWeight::BOLD) + .text_color(badge_color) + .child(badge), + ) + .child( + v_flex() + .gap_0p5() + .flex_1() + .min_w_0() + .child(div().text_sm().text_color(theme.foreground).child(flow)) + .when_some(forward.description.clone(), |el, desc| { + el.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(desc), + ) + }) + .child(div().text_xs().text_color(status_color).child(status_text)), + ) + .child( + Button::new(("ssh-managed-forward-del", forward_id as usize)) + .label("Delete") + .small() + .on_click(cx.listener(move |this, _, _window, cx| { + this.remove_managed_forward(pane_id, forward_id, cx) + })), + ) + } + fn render_loopback_forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { let theme = cx.theme(); let host_input = self.loopback_panel.host_input.clone(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index a7988981..dffa1be5 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -678,7 +678,7 @@ impl Tty7App { /// speak the same segmented language as the −│value│+ stepper; `small` pins /// every option control to the same 24px height as the selects beside them. /// `selected` is the active index; `on_pick` fires with the newly chosen one. - fn segmented( + pub(crate) fn segmented( &self, id: &'static str, options: &'static [&'static str],