diff --git a/crates/omnyssh-core/Cargo.toml b/crates/omnyssh-core/Cargo.toml index 00ad3dc..39ae27a 100644 --- a/crates/omnyssh-core/Cargo.toml +++ b/crates/omnyssh-core/Cargo.toml @@ -9,7 +9,8 @@ keywords = ["ssh", "devops"] categories = ["network-programming"] [dependencies] -tokio = { workspace = true } +# `net`: the local listeners of port forwarding. +tokio = { workspace = true, features = ["net"] } # SSH russh = "0.46" diff --git a/crates/omnyssh-core/src/config/ssh_config.rs b/crates/omnyssh-core/src/config/ssh_config.rs index 1d73dab..2da8c21 100644 --- a/crates/omnyssh-core/src/config/ssh_config.rs +++ b/crates/omnyssh-core/src/config/ssh_config.rs @@ -1,7 +1,8 @@ //! Parser for `~/.ssh/config`. //! //! Supported directives: `Host`, `HostName`, `User`, `Port`, -//! `IdentityFile`, `ProxyJump`, `Include`. +//! `IdentityFile`, `ProxyJump`, `LocalForward`, `Include`. `Match` blocks are +//! skipped. //! //! The original file is **never modified**. @@ -9,6 +10,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::ssh::client::{Host, HostSource}; +use crate::ssh::tunnel::LocalForward; /// Parses the text of an SSH config file and returns all non-wildcard hosts. /// @@ -120,6 +122,26 @@ fn parse_content( h.proxy_jump = Some(value.to_string()); } } + "localforward" if !in_wildcard => { + if let Some(ref mut h) = current { + match parse_local_forward(value) { + Ok(forward) => h.local_forwards.push(forward), + Err(e) => { + tracing::warn!(host = %h.name, error = %e, "LocalForward skipped") + } + } + } + } + // A Match block's directives apply by condition, not to the host + // above it; skip them like a wildcard block — a `LocalForward` there + // must not open a port for a host that never asked for it. + "match" => { + if let Some(h) = current.take() { + hosts.push(h); + } + hosts.append(&mut deferred); + in_wildcard = true; + } // An Include may sit inside a Host block; the enclosing host keeps // collecting directives after it. "include" => { @@ -179,6 +201,17 @@ fn parse_content( hosts } +/// Parses a `LocalForward` value: `[bind_address:]port host:hostport`, the two +/// arguments `ssh_config(5)` takes, joined into the `ssh -L` notation. +fn parse_local_forward(value: &str) -> Result { + match value.split_whitespace().collect::>().as_slice() { + [listen, target] => format!("{listen}:{target}").parse(), + _ => Err(format!( + "expected '[bind_address:]port host:hostport', got '{value}'" + )), + } +} + /// Removes everything from the first `#` onwards (inline comments). fn strip_comment(line: &str) -> &str { match line.find('#') { @@ -443,6 +476,85 @@ host server1 assert_eq!(hosts[0].source, crate::ssh::client::HostSource::SshConfig); } + #[test] + fn test_local_forwards() { + let cfg = "\ +Host nas + HostName 10.0.0.5 + LocalForward 9443 127.0.0.1:9443 + LocalForward localhost:5432 db.internal:5432 + LocalForward [::1]:8080 [fe80::1]:80 +"; + let hosts = parse_ssh_config(cfg); + let specs: Vec = hosts[0] + .local_forwards + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + specs, + [ + "9443:127.0.0.1:9443", + "localhost:5432:db.internal:5432", + "[::1]:8080:[fe80::1]:80", + ] + ); + } + + #[test] + fn test_unusable_local_forward_skipped() { + // A Unix-socket forward, a missing target and a bad port are dropped + // one by one; the host and its good forward survive. + let cfg = "\ +Host nas + LocalForward /tmp/local.sock /run/remote.sock + LocalForward 9443 + LocalForward 99999 localhost:80 + LocalForward 3000 localhost:3000 +"; + let hosts = parse_ssh_config(cfg); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].local_forwards.len(), 1); + assert_eq!( + hosts[0].local_forwards[0].to_string(), + "3000:localhost:3000" + ); + } + + #[test] + fn test_match_block_not_attached_to_previous_host() { + let cfg = "\ +Host web + HostName 10.0.0.1 + +Match host db* + User postgres + LocalForward 0.0.0.0:3306 db:3306 + +Host api + HostName 10.0.0.2 +"; + let hosts = parse_ssh_config(cfg); + assert_eq!(hosts.len(), 2); + assert!(hosts[0].local_forwards.is_empty()); + assert_ne!(hosts[0].user, "postgres"); + assert_eq!(hosts[1].name, "api"); + assert_eq!(hosts[1].hostname, "10.0.0.2"); + } + + #[test] + fn test_wildcard_local_forward_ignored() { + let cfg = "\ +Host * + LocalForward 8080 localhost:80 + +Host web + HostName 10.0.0.1 +"; + let hosts = parse_ssh_config(cfg); + assert!(hosts[0].local_forwards.is_empty()); + } + #[test] fn test_equals_separator() { // Some configs use '=' instead of space. diff --git a/crates/omnyssh-core/src/event.rs b/crates/omnyssh-core/src/event.rs index 58dbb64..97abbb7 100644 --- a/crates/omnyssh-core/src/event.rs +++ b/crates/omnyssh-core/src/event.rs @@ -11,6 +11,7 @@ use crate::config::snippets::Snippet; use crate::ssh::client::{ConnectionStatus, Host}; use crate::ssh::key_setup::KeySetupStep; use crate::ssh::sftp::FileEntry; +use crate::ssh::tunnel::TunnelStatus; /// Placeholder type aliases for future stages. /// `HostId` is the host's `name` field — stable, human-readable key. @@ -131,6 +132,12 @@ pub enum CoreEvent { /// Discovery failed for a host with an error message. DiscoveryFailed(HostId, String), + // ----------------------------------------------------------------------- + // Port forwarding + // ----------------------------------------------------------------------- + /// A host's tunnel changed state. + TunnelStatusChanged(HostId, TunnelStatus), + // ----------------------------------------------------------------------- // Auto SSH Key Setup events // ----------------------------------------------------------------------- diff --git a/crates/omnyssh-core/src/ssh/client.rs b/crates/omnyssh-core/src/ssh/client.rs index 8f1c402..a657d44 100644 --- a/crates/omnyssh-core/src/ssh/client.rs +++ b/crates/omnyssh-core/src/ssh/client.rs @@ -3,7 +3,9 @@ //! Connections delegated to the system SSH binary. //! Also provides russh-based client for live metrics. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::ssh::tunnel::LocalForward; /// Indicates where a host entry originated. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -82,6 +84,16 @@ pub struct Host { /// Port for the reachability probe. Falls back to `port` when unset. #[serde(skip_serializing_if = "Option::is_none")] pub monitor_port: Option, + /// Local port forwards (`ssh -L`) carried by this host's tunnel. + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "readable_forwards" + )] + pub local_forwards: Vec, + /// Start the tunnel when OmnySSH starts. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tunnel_autostart: bool, // ----------------------------------------------------------------------- // Auto SSH Key Setup metadata @@ -94,6 +106,19 @@ pub struct Host { pub password_auth_disabled: Option, } +/// Reads the forwards one by one, dropping an unreadable rule with a warning: +/// failing it would fail the whole file, and every manual host with it. +fn readable_forwards<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Vec::::deserialize(d)? + .into_iter() + .filter_map(|spec| { + spec.parse() + .map_err(|e| tracing::warn!(error = %e, "port forward skipped")) + .ok() + }) + .collect()) +} + fn default_user() -> String { std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) @@ -120,6 +145,8 @@ impl Default for Host { original_ssh_host: None, monitoring: MonitorMode::default(), monitor_port: None, + local_forwards: Vec::new(), + tunnel_autostart: false, key_setup_date: None, password_auth_disabled: None, } @@ -173,6 +200,22 @@ mod tests { } } + /// A hand-edited typo costs only the rule it is in, not the file. + #[test] + fn an_unreadable_forward_is_skipped_not_fatal() { + let host: Host = toml::from_str( + "name = \"nas\"\nhostname = \"10.0.0.5\"\n\ + local_forwards = [\"9443:localhost:9443\", \"94430:localhost\"]\n", + ) + .expect("a bad rule must not fail the host"); + let specs: Vec = host + .local_forwards + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!(specs, ["9443:localhost:9443"]); + } + #[test] fn a_reachability_host_persists_its_mode_and_probe_port() { let host = Host { diff --git a/crates/omnyssh-core/src/ssh/mod.rs b/crates/omnyssh-core/src/ssh/mod.rs index 85be5f0..416ef8c 100644 --- a/crates/omnyssh-core/src/ssh/mod.rs +++ b/crates/omnyssh-core/src/ssh/mod.rs @@ -1,8 +1,8 @@ /// SSH client, session management, SFTP and metrics collection. /// /// A native russh client powers metrics collection, SFTP, and the -/// multi-session terminal emulator, plus Smart Server Context with service -/// discovery and Auto SSH Key Setup for secure authentication. +/// multi-session terminal emulator and local port forwarding, plus Smart Server +/// Context with service discovery and Auto SSH Key Setup for secure authentication. pub mod client; pub mod discovery; pub mod jump; @@ -14,3 +14,4 @@ pub mod pty; pub mod services; pub mod session; pub mod sftp; +pub mod tunnel; diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 917627f..27a7c6c 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -12,6 +12,8 @@ //! - Connect timeout: 10 seconds (per hop) //! - Command timeout: 30 seconds +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -38,6 +40,10 @@ pub(crate) struct KnownHostsHandler { host: String, /// Port used for known_hosts lookup. port: u16, + /// Set when the server ends the session with a DISCONNECT of its own, as + /// OpenSSH does after too many failed logins. A link that just dies leaves + /// it unset. + hung_up: Arc, } #[async_trait] @@ -100,6 +106,59 @@ impl client::Handler for KnownHostsHandler { } } } + + async fn disconnected( + &mut self, + reason: client::DisconnectReason, + ) -> Result<(), Self::Error> { + match reason { + client::DisconnectReason::ReceivedDisconnect(_) => { + self.hung_up.store(true, Ordering::SeqCst); + Ok(()) + } + client::DisconnectReason::Error(e) => Err(e), + } + } +} + +// --------------------------------------------------------------------------- +// Refused +// --------------------------------------------------------------------------- + +/// A connection the server turned away on purpose: it refused every credential, +/// or its host key no longer matches `known_hosts`. A type of its own so a caller +/// that reconnects by itself can stop instead of piling up failed logins. +#[derive(Debug)] +pub(crate) struct Refused(String); + +impl fmt::Display for Refused { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Refused {} + +/// Whether `e` is, or wraps, a refused connection. +pub(crate) fn is_refused(e: &anyhow::Error) -> bool { + e.chain().any(|cause| { + cause.is::() + || matches!( + cause.downcast_ref::(), + Some(russh::Error::UnknownKey) + ) + }) +} + +/// Prefixes a hop's error with where it failed. The message is flattened, as +/// before, but a refusal stays recognisable through the jump chain. +fn at_hop(e: anyhow::Error, context: String) -> anyhow::Error { + let message = format!("{context}: {e:#}"); + if is_refused(&e) { + Refused(message).into() + } else { + anyhow!(message) + } } // --------------------------------------------------------------------------- @@ -275,14 +334,14 @@ pub(crate) async fn connect_and_auth(host: &Host) -> anyhow::Result connect_direct(&config, hop).await, Some(via) => connect_tunnelled(&config, via, hop).await, } - .map_err(|e| anyhow!("ProxyJump via '{}' failed: {e:#}", hop.name))?; + .map_err(|e| at_hop(e, format!("ProxyJump via '{}' failed", hop.name)))?; jumps.push(handle); } let handle = match (jumps.last(), chain.last()) { (Some(via), Some(last)) => connect_tunnelled(&config, via, host) .await - .map_err(|e| anyhow!("connecting via '{}' failed: {e:#}", last.name))?, + .map_err(|e| at_hop(e, format!("connecting via '{}' failed", last.name)))?, _ => connect_direct(&config, host).await?, }; @@ -355,15 +414,20 @@ async fn connect_direct( host: &Host, ) -> anyhow::Result> { let addr = format!("{}:{}", host.hostname, host.port); + let hung_up = Arc::new(AtomicBool::new(false)); let handle = time::timeout( CONNECT_TIMEOUT, - client::connect(Arc::clone(config), addr, known_hosts_handler(host)), + client::connect( + Arc::clone(config), + addr, + known_hosts_handler(host, &hung_up), + ), ) .await .map_err(|_| anyhow!("SSH connection timed out (10 s)"))? .context("SSH connection failed")?; - finish_auth(handle, host).await + finish_auth(handle, host, &hung_up).await } /// Reaches `host` through the already-connected bastion `via`: a `direct-tcpip` @@ -388,28 +452,30 @@ async fn connect_tunnelled( .map_err(|_| anyhow!("SSH connection timed out (10 s)"))? .with_context(|| format!("open tunnel to {}:{}", host.hostname, host.port))?; + let hung_up = Arc::new(AtomicBool::new(false)); let handle = time::timeout( CONNECT_TIMEOUT, client::connect_stream( Arc::clone(config), channel.into_stream(), - known_hosts_handler(host), + known_hosts_handler(host, &hung_up), ), ) .await .map_err(|_| anyhow!("SSH connection timed out (10 s)"))? .context("SSH connection failed")?; - finish_auth(handle, host).await + finish_auth(handle, host, &hung_up).await } /// The host-key verifier for `host`. The lookup uses the target's own /// hostname/port even over a tunnel, so `known_hosts` entries match what an /// `ssh -J` would record. -fn known_hosts_handler(host: &Host) -> KnownHostsHandler { +fn known_hosts_handler(host: &Host, hung_up: &Arc) -> KnownHostsHandler { KnownHostsHandler { host: host.hostname.clone(), port: host.port, + hung_up: Arc::clone(hung_up), } } @@ -417,9 +483,21 @@ fn known_hosts_handler(host: &Host) -> KnownHostsHandler { async fn finish_auth( mut handle: Handle, host: &Host, + hung_up: &AtomicBool, ) -> anyhow::Result> { if !authenticate(&mut handle, host).await? { - return Err(anyhow!("SSH authentication failed for {}", host.name)); + let message = format!("SSH authentication failed for {}", host.name); + // `authenticate` folds a dropped link into "not accepted". A connection + // that is gone refused us only if the server hung up itself, as OpenSSH + // does after too many failed logins. russh records that as the session + // winds down, so let it finish first. + if handle.is_closed() { + let _ = time::timeout(Duration::from_secs(1), &mut handle).await; + if !hung_up.load(Ordering::SeqCst) { + return Err(anyhow!(message)); + } + } + return Err(Refused(message).into()); } Ok(handle) } diff --git a/crates/omnyssh-core/src/ssh/tunnel.rs b/crates/omnyssh-core/src/ssh/tunnel.rs new file mode 100644 index 0000000..0c2f82c --- /dev/null +++ b/crates/omnyssh-core/src/ssh/tunnel.rs @@ -0,0 +1,736 @@ +//! Local port forwarding — the `ssh -L` / `LocalForward` model. +//! +//! A host's tunnel is one SSH connection carrying every [`LocalForward`] the +//! host defines. Each forward listens on a local port, and every connection it +//! accepts rides its own `direct-tcpip` channel to `remote_host:remote_port`, +//! which the server resolves — so `localhost` there is the server's own loopback. +//! +//! [`TunnelManager`] runs one task per host. The local ports are bound before the +//! first dial and held until the tunnel stops, so a dropped connection never +//! hands them to another process: the task reconnects with a backoff, and +//! connections that arrive meanwhile wait in the listen backlog. Every change is +//! reported as [`CoreEvent::TunnelStatusChanged`]. + +use std::collections::HashMap; +use std::fmt; +use std::future::poll_fn; +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::str::FromStr; +use std::sync::Arc; +use std::task::Poll; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use tokio::io::AsyncWriteExt; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::{JoinHandle, JoinSet}; +use tokio::time; + +use crate::event::CoreEvent; +use crate::ssh::client::Host; +use crate::ssh::session::{connect_and_auth, connect_budget, is_refused, SshConnection}; + +// --------------------------------------------------------------------------- +// LocalForward +// --------------------------------------------------------------------------- + +/// One `ssh -L` rule: `[bind_address:]port:host:hostport`. +/// +/// Stored in `hosts.toml` in that same notation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(into = "String")] +pub struct LocalForward { + /// Local address to listen on. `None` and `localhost` mean the loopback + /// only, as with ssh; `*` or an empty address mean every interface. + pub bind_address: Option, + /// Local port to listen on. + pub bind_port: u16, + /// Where the server connects to, resolved on the server's side. + pub remote_host: String, + /// The port the server connects to. + pub remote_port: u16, +} + +impl FromStr for LocalForward { + type Err = String; + + fn from_str(spec: &str) -> Result { + let spec = spec.trim(); + let usage = || format!("expected [bind_address:]port:host:hostport, got '{spec}'"); + let fields = split_fields(spec).ok_or_else(usage)?; + let (bind_address, bind_port, remote_host, remote_port) = match fields.as_slice() { + [port, host, hostport] => (None, *port, *host, *hostport), + [bind, port, host, hostport] => (Some(*bind), *port, *host, *hostport), + _ => return Err(usage()), + }; + if remote_host.is_empty() { + return Err(usage()); + } + Ok(Self { + bind_address: bind_address.map(str::to_string), + bind_port: parse_port(bind_port, spec)?, + remote_host: remote_host.to_string(), + remote_port: parse_port(remote_port, spec)?, + }) + } +} + +impl fmt::Display for LocalForward { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(bind) = &self.bind_address { + write!(f, "{}:", bracketed(bind))?; + } + write!( + f, + "{}:{}:{}", + self.bind_port, + bracketed(&self.remote_host), + self.remote_port + ) + } +} + +impl From for String { + fn from(forward: LocalForward) -> Self { + forward.to_string() + } +} + +/// Splits on the colons outside `[...]`, unwrapping a bracketed IPv6 address. +/// `None` when a bracket is left open. +fn split_fields(spec: &str) -> Option> { + let mut fields = Vec::new(); + let mut start = 0; + let mut in_brackets = false; + for (i, c) in spec.char_indices() { + match c { + '[' => in_brackets = true, + ']' => in_brackets = false, + ':' if !in_brackets => { + fields.push(&spec[start..i]); + start = i + 1; + } + _ => {} + } + } + if in_brackets { + return None; + } + fields.push(&spec[start..]); + Some( + fields + .into_iter() + .map(|f| { + f.strip_prefix('[') + .and_then(|f| f.strip_suffix(']')) + .unwrap_or(f) + }) + .collect(), + ) +} + +fn parse_port(value: &str, spec: &str) -> Result { + value + .parse::() + .ok() + .filter(|&p| p != 0) + .ok_or_else(|| format!("'{value}' is not a port between 1 and 65535 in '{spec}'")) +} + +/// An IPv6 address needs brackets to survive the colon-separated notation. +fn bracketed(address: &str) -> String { + if address.contains(':') { + format!("[{address}]") + } else { + address.to_string() + } +} + +// --------------------------------------------------------------------------- +// TunnelStatus +// --------------------------------------------------------------------------- + +/// Where a host's tunnel stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TunnelStatus { + /// Dialling the host: the first attempt, or the next one after a drop. + Connecting, + /// Connected, with every forward listening. + Up, + /// The connection failed or dropped; another attempt follows a backoff. + /// The ports stay bound in the meantime. + Retrying(String), + /// Ended and will not retry: a port could not be bound, or the server + /// refused the credentials or its host key. + Failed(String), + /// Stopped on request. + Stopped, +} + +// --------------------------------------------------------------------------- +// TunnelManager +// --------------------------------------------------------------------------- + +/// How long after a failed or dropped connection the next dial waits. The last +/// step repeats. +const RETRY_DELAYS: [Duration; 5] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(30), +]; + +/// A connection that lasted this long resets the backoff. Resetting on connect +/// alone would redial every second a server that accepts and then drops us. +const STABLE_AFTER: Duration = Duration::from_secs(60); + +/// Head room over [`connect_budget`] for authentication, which has no timeout +/// of its own. +const AUTH_BUDGET: Duration = Duration::from_secs(20); + +/// How often a live tunnel checks that its connection still is. A dead peer +/// is noticed by the keepalives first; this only picks that up. +const LIVENESS_CHECK: Duration = Duration::from_secs(1); + +/// Runs the tunnels of every host, one task each. +/// +/// Dropping the manager stops them all. +pub struct TunnelManager { + tx: mpsc::Sender, + runs: HashMap, +} + +/// One host's tunnel task. A stopped run stays listed until it has wound down, +/// so a start right after it still waits for the ports it holds. +struct Run { + /// The host as the tunnel was started, to tell a later edit apart. + host: Host, + /// Dropped or fired, it ends the task; `None` once it has been asked to. + stop: Option>, + task: JoinHandle<()>, +} + +impl Run { + /// Asks the task to end; it reports [`TunnelStatus::Stopped`] on its way out. + fn stop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + } +} + +impl TunnelManager { + /// Creates a manager that reports on `tx`. + pub fn new(tx: mpsc::Sender) -> Self { + Self { + tx, + runs: HashMap::new(), + } + } + + /// Starts `host`'s tunnel, or restarts it when one is already running. The + /// new run waits for the old one to release the ports before binding them. + /// + /// Must be called within a tokio runtime. + pub fn start(&mut self, host: Host) { + let previous = self.runs.remove(&host.name).map(|mut run| { + run.stop(); + run.task + }); + let (stop, stop_rx) = oneshot::channel(); + let task = tokio::spawn(run_tunnel(host.clone(), self.tx.clone(), stop_rx, previous)); + let stop = Some(stop); + self.runs + .insert(host.name.clone(), Run { host, stop, task }); + } + + /// Starts the tunnel of every host marked to start on launch. + pub fn autostart(&mut self, hosts: &[Host]) { + for host in hosts { + if host.tunnel_autostart && !host.local_forwards.is_empty() { + self.start(host.clone()); + } + } + } + + /// Stops `name`'s tunnel. Does nothing when there is none. + pub fn stop(&mut self, name: &str) { + if let Some(run) = self.runs.get_mut(name) { + run.stop(); + } + } + + /// Whether `name` has a tunnel that has neither ended nor been stopped. + pub fn is_running(&self, name: &str) -> bool { + self.runs + .get(name) + .is_some_and(|run| run.stop.is_some() && !run.task.is_finished()) + } + + /// Brings the running tunnels in line with an edited host list: a host that + /// is gone, or has no forwards left, is stopped, and one whose connection or + /// forwards changed is restarted. Nothing is started that was not running. + pub fn sync(&mut self, hosts: &[Host]) { + self.runs.retain(|_, run| !run.task.is_finished()); + let names: Vec = self.runs.keys().cloned().collect(); + for name in names { + if !self.is_running(&name) { + continue; + } + match hosts.iter().find(|h| h.name == name) { + Some(host) if host.local_forwards.is_empty() => self.stop(&name), + Some(host) if self.runs.get(&name).is_some_and(|r| changed(&r.host, host)) => { + self.start(host.clone()); + } + Some(_) => {} + None => self.stop(&name), + } + } + } + + /// Stops every tunnel. + pub fn shutdown(mut self) { + for run in self.runs.values_mut() { + run.stop(); + } + } +} + +/// Whether an edit touched anything a running tunnel was started with. +fn changed(before: &Host, after: &Host) -> bool { + before.hostname != after.hostname + || before.port != after.port + || before.user != after.user + || before.identity_file != after.identity_file + || before.password != after.password + || before.proxy_jump != after.proxy_jump + || before.local_forwards != after.local_forwards +} + +// --------------------------------------------------------------------------- +// Tunnel task +// --------------------------------------------------------------------------- + +async fn run_tunnel( + host: Host, + tx: mpsc::Sender, + mut stop: oneshot::Receiver<()>, + previous: Option>, +) { + // A restart must not race its predecessor for the ports — even when it is + // stopped first, or the next run would inherit that race. The predecessor has + // been told to stop, so this is short. + if let Some(previous) = previous { + let _ = previous.await; + } + let status = tokio::select! { + biased; + _ = &mut stop => TunnelStatus::Stopped, + status = serve(&host, &tx) => status, + }; + // `serve` is dropped by now, so the ports are free again. + send_status(&tx, &host.name, status).await; +} + +/// Holds the ports and keeps the connection up. Returns only once the tunnel +/// cannot go on. +async fn serve(host: &Host, tx: &mpsc::Sender) -> TunnelStatus { + if host.local_forwards.is_empty() { + return TunnelStatus::Failed(String::from("no port forwards are set up for this host")); + } + let listeners = match bind_all(&host.local_forwards).await { + Ok(listeners) => listeners, + Err(e) => return TunnelStatus::Failed(e), + }; + + // Only the first dial reports Connecting; a retry keeps showing why the last + // attempt failed until one succeeds. + send_status(tx, &host.name, TunnelStatus::Connecting).await; + let mut retry = 0; + loop { + let budget = connect_budget(host).await + AUTH_BUDGET; + let reason = match time::timeout(budget, connect_and_auth(host)).await { + Ok(Ok(conn)) => { + send_status(tx, &host.name, TunnelStatus::Up).await; + let since = Instant::now(); + let reason = forward(Arc::new(conn), &listeners, tx, &host.name).await; + if since.elapsed() >= STABLE_AFTER { + retry = 0; + } + reason + } + Ok(Err(e)) if is_refused(&e) => return TunnelStatus::Failed(format!("{e:#}")), + Ok(Err(e)) => format!("{e:#}"), + Err(_) => format!( + "no answer from {} within {}s", + host.hostname, + budget.as_secs() + ), + }; + tracing::debug!(host = %host.name, %reason, "tunnel down"); + send_status(tx, &host.name, TunnelStatus::Retrying(reason)).await; + time::sleep(RETRY_DELAYS[retry]).await; + retry = (retry + 1).min(RETRY_DELAYS.len() - 1); + } +} + +/// A bound forward: its rule, and every socket it listens on. +type Bound = (LocalForward, Vec); + +/// Binds every forward, or none: a tunnel missing one of its ports would look +/// up while quietly not serving it. +async fn bind_all(forwards: &[LocalForward]) -> Result, String> { + let mut bound = Vec::with_capacity(forwards.len()); + for forward in forwards { + bound.push((forward.clone(), bind(forward).await?)); + } + Ok(bound) +} + +/// Binds one forward's addresses. The loopback is IPv4 plus, where the system +/// has one, IPv6 — as ssh does — so `localhost` reaches the tunnel whichever +/// family a client tries first. +async fn bind(forward: &LocalForward) -> Result, String> { + let port = forward.bind_port; + let failed = |address: &str, e: io::Error| format!("cannot listen on {address}:{port}: {e}"); + let (required, optional): (Vec, Vec) = + match forward.bind_address.as_deref() { + None | Some("localhost") => ( + vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))], + vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))], + ), + Some("" | "*") => ( + vec![SocketAddr::from((Ipv4Addr::UNSPECIFIED, port))], + vec![], + ), + Some(address) => match address.parse::() { + Ok(ip) => (vec![SocketAddr::new(ip, port)], vec![]), + Err(_) => ( + tokio::net::lookup_host((address, port)) + .await + .map_err(|e| failed(address, e))? + .collect(), + vec![], + ), + }, + }; + + let mut listeners = Vec::new(); + for address in required { + listeners.push( + TcpListener::bind(address) + .await + .map_err(|e| failed(&address.ip().to_string(), e))?, + ); + } + for address in optional { + match TcpListener::bind(address).await { + Ok(listener) => listeners.push(listener), + Err(e) => tracing::debug!(%address, error = %e, "optional forward address skipped"), + } + } + Ok(listeners) +} + +/// Serves the forwards over `conn` until the connection closes, and returns why. +/// +/// Every accepted connection runs as its own task; they all end with this call, +/// since their channels die with the connection anyway. +async fn forward( + conn: Arc, + listeners: &[Bound], + tx: &mpsc::Sender, + name: &str, +) -> String { + let mut streams = JoinSet::new(); + let mut liveness = time::interval(LIVENESS_CHECK); + loop { + tokio::select! { + (index, accepted) = accept(listeners) => match accepted { + Ok((socket, peer)) => { + let rule = listeners[index].0.clone(); + let (tx, name) = (tx.clone(), name.to_string()); + streams.spawn(carry(Arc::clone(&conn), rule, socket, peer, tx, name)); + } + // Per-connection trouble such as a full file table; the listener + // itself is fine, so back off briefly instead of spinning. + Err(e) => { + tracing::warn!(error = %e, "accepting a forwarded connection failed"); + time::sleep(Duration::from_millis(100)).await; + } + }, + _ = liveness.tick() => { + if conn.is_closed() { + return String::from("connection lost"); + } + } + Some(_) = streams.join_next() => {} + } + } +} + +/// The next connection on any forward's listeners, with the forward's index. +async fn accept(listeners: &[Bound]) -> (usize, io::Result<(TcpStream, SocketAddr)>) { + poll_fn(|cx| { + for (index, (_, sockets)) in listeners.iter().enumerate() { + for socket in sockets { + if let Poll::Ready(accepted) = socket.poll_accept(cx) { + return Poll::Ready((index, accepted)); + } + } + } + Poll::Pending + }) + .await +} + +/// Carries one accepted connection over its own `direct-tcpip` channel. +/// +/// The open waits as long as the server takes to reach the target, as ssh does: +/// giving up on it early would leave a channel the server still opens. +async fn carry( + conn: Arc, + forward: LocalForward, + socket: TcpStream, + peer: SocketAddr, + tx: mpsc::Sender, + name: String, +) { + let opened = conn + .channel_open_direct_tcpip( + forward.remote_host.as_str(), + u32::from(forward.remote_port), + peer.ip().to_string(), + u32::from(peer.port()), + ) + .await; + let mut channel = match opened { + Ok(channel) => channel, + // The tunnel itself is up, so its status stays; say why this one + // connection went nowhere — a stopped service, a server that forbids + // forwarding — or the user only sees a reset. + Err(e) => { + let message = format!("Tunnel to '{name}': {forward} could not be opened: {e}"); + let _ = tx.send(CoreEvent::Error(message)).await; + return; + } + }; + if let Err(e) = pump(socket, &mut channel).await { + tracing::debug!(%forward, error = %e, "forwarded connection ended"); + } + // A channel does not close when dropped: without this, every connection that + // ended abruptly would hold its channel, and the server's socket to the + // target, until the tunnel itself went down. + let _ = channel.close().await; +} + +/// Copies both ways, passing EOF along, until the remote side is done. +/// +/// A local EOF only half-closes: the reply still comes back. The remote end +/// finishes it all, as ssh closes the local socket once the channel closes — the +/// reader cannot tell a remote EOF from a close, and writing on after a close +/// spins in russh until the local peer gives up. +async fn pump( + socket: TcpStream, + channel: &mut russh::Channel, +) -> io::Result<()> { + let (mut local_rx, mut local_tx) = socket.into_split(); + let mut remote_tx = channel.make_writer(); + let mut remote_rx = channel.make_reader(); + let upstream = async { + tokio::io::copy(&mut local_rx, &mut remote_tx).await?; + remote_tx.shutdown().await + }; + let downstream = async { + tokio::io::copy(&mut remote_rx, &mut local_tx).await?; + local_tx.shutdown().await + }; + tokio::pin!(upstream, downstream); + let mut sending = true; + loop { + tokio::select! { + done = &mut downstream => return done, + done = &mut upstream, if sending => { + done?; + sending = false; + } + } + } +} + +async fn send_status(tx: &mpsc::Sender, name: &str, status: TunnelStatus) { + let _ = tx + .send(CoreEvent::TunnelStatusChanged(name.to_string(), status)) + .await; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(bind: Option<&str>, port: u16, host: &str, hostport: u16) -> LocalForward { + LocalForward { + bind_address: bind.map(str::to_string), + bind_port: port, + remote_host: host.to_string(), + remote_port: hostport, + } + } + + #[test] + fn the_ssh_notation_parses() { + let cases = [ + ("9443:127.0.0.1:9443", rule(None, 9443, "127.0.0.1", 9443)), + ( + "5432:db.internal:5432", + rule(None, 5432, "db.internal", 5432), + ), + ( + "localhost:8080:web:80", + rule(Some("localhost"), 8080, "web", 80), + ), + ( + "0.0.0.0:8080:web:80", + rule(Some("0.0.0.0"), 8080, "web", 80), + ), + ("*:8080:web:80", rule(Some("*"), 8080, "web", 80)), + (":8080:web:80", rule(Some(""), 8080, "web", 80)), + ( + "[::1]:8080:[fe80::1]:80", + rule(Some("::1"), 8080, "fe80::1", 80), + ), + ("8080:[::1]:80", rule(None, 8080, "::1", 80)), + ( + " 3000:localhost:3000 ", + rule(None, 3000, "localhost", 3000), + ), + ]; + for (spec, expected) in cases { + assert_eq!(spec.parse::(), Ok(expected), "{spec}"); + } + } + + #[test] + fn a_malformed_rule_is_rejected() { + for spec in [ + "", + "8080", + "8080:web", + "a:b:c:d:e", + "0:web:80", + "8080:web:0", + "70000:web:80", + "8080:web:http", + "8080::80", + "[::1:8080:web:80", + "-1:web:80", + ] { + assert!( + spec.parse::().is_err(), + "'{spec}' should be rejected" + ); + } + } + + #[test] + fn a_rule_round_trips_through_its_notation() { + for spec in [ + "9443:127.0.0.1:9443", + "localhost:8080:web:80", + ":8080:web:80", + "[::1]:8080:[fe80::1]:80", + ] { + let parsed: LocalForward = spec.parse().expect("valid"); + assert_eq!(parsed.to_string(), spec); + } + } + + #[test] + fn a_host_stores_its_forwards_in_the_ssh_notation() { + let host = Host { + name: String::from("nas"), + hostname: String::from("10.0.0.5"), + local_forwards: vec![rule(None, 9443, "127.0.0.1", 9443)], + tunnel_autostart: true, + ..Host::default() + }; + let written = toml::to_string(&host).expect("serialize"); + assert!( + written.contains("local_forwards = [\"9443:127.0.0.1:9443\"]"), + "{written}" + ); + assert!(written.contains("tunnel_autostart = true"), "{written}"); + + let read: Host = toml::from_str(&written).expect("deserialize"); + assert_eq!(read.local_forwards, host.local_forwards); + assert!(read.tunnel_autostart); + } + + #[test] + fn an_unforwarded_host_leaves_hosts_toml_untouched() { + let host: Host = + toml::from_str("name = \"web\"\nhostname = \"10.0.0.1\"\n").expect("parse"); + assert!(host.local_forwards.is_empty()); + assert!(!host.tunnel_autostart); + + let written = toml::to_string(&host).expect("serialize"); + assert!(!written.contains("local_forwards"), "{written}"); + assert!(!written.contains("tunnel_autostart"), "{written}"); + } + + #[test] + fn only_a_tunnel_input_counts_as_a_change() { + let before = Host { + name: String::from("nas"), + hostname: String::from("10.0.0.5"), + local_forwards: vec![rule(None, 9443, "127.0.0.1", 9443)], + ..Host::default() + }; + + let mut cosmetic = before.clone(); + cosmetic.tags = vec![String::from("home")]; + cosmetic.notes = Some(String::from("portainer")); + cosmetic.tunnel_autostart = true; + assert!(!changed(&before, &cosmetic)); + + let mut moved = before.clone(); + moved + .local_forwards + .push(rule(None, 5432, "localhost", 5432)); + assert!(changed(&before, &moved)); + + let mut readdressed = before.clone(); + readdressed.hostname = String::from("10.0.0.6"); + assert!(changed(&before, &readdressed)); + } + + #[tokio::test] + async fn a_port_in_use_fails_the_whole_tunnel_and_binds_nothing() { + let taken = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let busy = taken.local_addr().expect("addr").port(); + let free = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let spare = free.local_addr().expect("addr").port(); + drop(free); + + let forwards = [ + rule(None, spare, "localhost", 80), + rule(None, busy, "localhost", 80), + ]; + let err = bind_all(&forwards) + .await + .expect_err("the busy port must fail"); + assert!(err.contains(&busy.to_string()), "{err}"); + + // The forward that did bind was released along with the failure. + TcpListener::bind(("127.0.0.1", spare)) + .await + .expect("the spare port is free again"); + } +} diff --git a/crates/omnyssh-core/tests/tunnel.rs b/crates/omnyssh-core/tests/tunnel.rs new file mode 100644 index 0000000..c91aa26 --- /dev/null +++ b/crates/omnyssh-core/tests/tunnel.rs @@ -0,0 +1,699 @@ +//! Port forwarding end to end, against an in-process SSH server. +//! +//! The server takes one password and opens `direct-tcpip` channels to loopback +//! targets, so every tunnel here goes through a real handshake, authentication +//! and channel traffic without a system `sshd`. A relay between the tunnel and +//! the server stands in for the network, so a test can cut it. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, Once}; +use std::time::Duration; + +use russh::keys::key::KeyPair; +use russh::server::{self, Auth, Msg, Session}; +use russh::Channel; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio::task::{JoinHandle, JoinSet}; + +use omnyssh_core::event::CoreEvent; +use omnyssh_core::ssh::client::Host; +use omnyssh_core::ssh::tunnel::{LocalForward, TunnelManager, TunnelStatus}; + +const PASSWORD: &str = "tunnel-test"; +/// A password the server answers by dropping the connection mid-login. +const DROPS_THE_LINE: &str = "drop-the-line"; + +/// Keeps trust-on-first-use off the real `~/.ssh/known_hosts`, and the local +/// agent's keys out of the login. +fn isolate_home() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let home = tempfile::tempdir().expect("tempdir").keep(); + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + std::env::remove_var("SSH_AUTH_SOCK"); + }); +} + +// --------------------------------------------------------------------------- +// SSH server +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +struct TestServer { + /// Its own session once running, to hang up on a client the way OpenSSH does. + session: Arc>>, +} + +#[async_trait::async_trait] +impl server::Handler for TestServer { + type Error = russh::Error; + + async fn auth_password(&mut self, _user: &str, password: &str) -> Result { + if password == DROPS_THE_LINE { + return Err(russh::Error::Disconnect); + } + Ok(if password == PASSWORD { + Auth::Accept + } else { + Auth::Reject { + proceed_with_methods: None, + } + }) + } + + /// Every key is turned down and the server then hangs up, like OpenSSH past + /// its MaxAuthTries. + async fn auth_publickey( + &mut self, + _user: &str, + _key: &russh::keys::key::PublicKey, + ) -> Result { + if let Some(session) = self.session.lock().unwrap().clone() { + tokio::spawn(async move { + let reason = russh::Disconnect::ProtocolError; + let text = String::from("Too many authentication failures"); + let _ = session.disconnect(reason, text, String::new()).await; + }); + } + Ok(Auth::Reject { + proceed_with_methods: None, + }) + } + + async fn channel_open_direct_tcpip( + &mut self, + channel: Channel, + host_to_connect: &str, + port_to_connect: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result { + // Like sshd, refuse the channel when the target does not answer. + let target = format!("{host_to_connect}:{port_to_connect}"); + let Ok(mut socket) = TcpStream::connect(target).await else { + return Ok(false); + }; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; + }); + Ok(true) + } +} + +/// An SSH server on a loopback port, and its host key. +async fn ssh_server() -> (SocketAddr, KeyPair) { + let key = KeyPair::generate_ed25519(); + let config = Arc::new(server::Config { + keys: vec![key.clone()], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + let handler = TestServer::default(); + if let Ok(running) = + server::run_stream(Arc::clone(&config), socket, handler.clone()).await + { + *handler.session.lock().unwrap() = Some(running.handle()); + } + } + }); + (addr, key) +} + +/// A target service that echoes back whatever it is sent, prefixed with `tag`. +async fn echo_service(tag: &'static str) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + let mut buf = [0u8; 256]; + while let Ok(n) = socket.read(&mut buf).await { + if n == 0 { + break; + } + let reply = [tag.as_bytes(), &buf[..n]].concat(); + if socket.write_all(&reply).await.is_err() { + break; + } + } + }); + } + }); + port +} + +// --------------------------------------------------------------------------- +// The network in between +// --------------------------------------------------------------------------- + +/// A TCP relay to the SSH server that a test can take down and bring back on +/// the same port, the way a network drop looks to the client. +struct Link { + port: u16, + server: SocketAddr, + dials: Arc, + relay: Mutex>>, +} + +impl Link { + async fn to(server: SocketAddr) -> Arc { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let link = Arc::new(Self { + port: listener.local_addr().expect("addr").port(), + server, + dials: Arc::new(AtomicUsize::new(0)), + relay: Mutex::new(None), + }); + link.serve(listener); + link + } + + fn serve(&self, listener: TcpListener) { + let (server, dials) = (self.server, Arc::clone(&self.dials)); + let relay = tokio::spawn(async move { + // Dropped with the relay, so a cut also severs the live connections. + let mut pipes = JoinSet::new(); + while let Ok((mut client, _)) = listener.accept().await { + dials.fetch_add(1, Ordering::SeqCst); + pipes.spawn(async move { + if let Ok(mut upstream) = TcpStream::connect(server).await { + let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await; + } + }); + } + }); + *self.relay.lock().unwrap() = Some(relay); + } + + /// Severs every connection and stops answering. + fn cut(&self) { + if let Some(relay) = self.relay.lock().unwrap().take() { + relay.abort(); + } + } + + /// Answers again, on the same port. + async fn restore(&self) { + let listener = TcpListener::bind(("127.0.0.1", self.port)) + .await + .expect("rebind the link"); + self.serve(listener); + } + + fn dials(&self) -> usize { + self.dials.load(Ordering::SeqCst) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// A loopback port nothing listens on right now. +async fn free_port() -> u16 { + let probe = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + probe.local_addr().expect("addr").port() +} + +fn forward(local: u16, target: u16) -> LocalForward { + format!("{local}:127.0.0.1:{target}") + .parse() + .expect("valid rule") +} + +fn host(name: &str, port: u16, password: &str, forwards: Vec) -> Host { + Host { + name: name.to_string(), + hostname: String::from("127.0.0.1"), + user: String::from("tester"), + port, + password: Some(password.to_string()), + local_forwards: forwards, + ..Host::default() + } +} + +/// Tunnel statuses as the manager reports them, kept apart per host so waiting +/// on one never swallows another's; errors are kept too. +struct Statuses { + rx: mpsc::Receiver, + held: Vec<(String, TunnelStatus)>, + errors: Vec, +} + +impl Statuses { + /// A manager and the statuses it reports. + fn manager() -> (TunnelManager, Self) { + let (tx, rx) = mpsc::channel(64); + ( + TunnelManager::new(tx), + Self { + rx, + held: Vec::new(), + errors: Vec::new(), + }, + ) + } + + /// The next status `name`'s tunnel reports. + async fn next(&mut self, name: &str) -> TunnelStatus { + if let Some(i) = self.held.iter().position(|(host, _)| host == name) { + return self.held.remove(i).1; + } + loop { + match self.recv().await { + CoreEvent::TunnelStatusChanged(host, status) if host == name => return status, + CoreEvent::TunnelStatusChanged(host, status) => self.held.push((host, status)), + CoreEvent::Error(message) => self.errors.push(message), + _ => {} + } + } + } + + /// The next error the manager reports. + async fn error(&mut self) -> String { + loop { + if !self.errors.is_empty() { + return self.errors.remove(0); + } + match self.recv().await { + CoreEvent::TunnelStatusChanged(host, status) => self.held.push((host, status)), + CoreEvent::Error(message) => self.errors.push(message), + _ => {} + } + } + } + + async fn recv(&mut self) -> CoreEvent { + tokio::time::timeout(Duration::from_secs(30), self.rx.recv()) + .await + .expect("an event within 30s") + .expect("the manager is alive") + } + + /// Skips `name`'s statuses until one matches, and returns it. + async fn until(&mut self, name: &str, wanted: impl Fn(&TunnelStatus) -> bool) -> TunnelStatus { + loop { + let status = self.next(name).await; + if wanted(&status) { + return status; + } + } + } +} + +/// Sends `message` through the local port and returns the reply. +async fn round_trip(port: u16, message: &str) -> String { + let mut socket = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect"); + socket.write_all(message.as_bytes()).await.expect("write"); + let mut buf = vec![0u8; 256]; + let n = tokio::time::timeout(Duration::from_secs(10), socket.read(&mut buf)) + .await + .expect("a reply within 10s") + .expect("read"); + String::from_utf8_lossy(&buf[..n]).into_owned() +} + +/// Whether something listens on the loopback `port`. Binds the way the tunnel +/// does, so connections still in TIME_WAIT on a released port do not count. +async fn is_bound(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).await.is_err() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Several hosts at once, each with several ports — the shape asked for in #89. +#[tokio::test] +async fn several_hosts_each_forward_several_ports() { + isolate_home(); + let (server, _) = ssh_server().await; + let (web, db, cache) = ( + echo_service("web:").await, + echo_service("db:").await, + echo_service("cache:").await, + ); + let (web_local, db_local, cache_local) = + (free_port().await, free_port().await, free_port().await); + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "app", + server.port(), + PASSWORD, + vec![forward(web_local, web), forward(db_local, db)], + )); + tunnels.start(host( + "cache", + server.port(), + PASSWORD, + vec![forward(cache_local, cache)], + )); + + assert_eq!(statuses.next("app").await, TunnelStatus::Connecting); + assert_eq!(statuses.next("app").await, TunnelStatus::Up); + statuses.until("cache", |s| *s == TunnelStatus::Up).await; + + let (a, b, c) = tokio::join!( + round_trip(web_local, "one"), + round_trip(db_local, "two"), + round_trip(cache_local, "three"), + ); + assert_eq!( + (a.as_str(), b.as_str(), c.as_str()), + ("web:one", "db:two", "cache:three") + ); + + // Parallel connections on one port each get a channel of their own. + let parallel: Vec<_> = (0..8) + .map(|i| tokio::spawn(round_trip(web_local, if i % 2 == 0 { "x" } else { "y" }))) + .collect(); + for (i, reply) in parallel.into_iter().enumerate() { + let expected = if i % 2 == 0 { "web:x" } else { "web:y" }; + assert_eq!(reply.await.expect("join"), expected); + } + assert!(tunnels.is_running("app") && tunnels.is_running("cache")); +} + +/// A dropped connection is dialled again on its own, and the local port is +/// held the whole time so nothing else can take it. +#[tokio::test] +async fn a_dropped_connection_comes_back_with_its_ports() { + isolate_home(); + let (server, _) = ssh_server().await; + let link = Link::to(server).await; + let target = echo_service("").await; + let local = free_port().await; + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "flaky", + link.port, + PASSWORD, + vec![forward(local, target)], + )); + statuses.until("flaky", |s| *s == TunnelStatus::Up).await; + assert_eq!(round_trip(local, "before").await, "before"); + + link.cut(); + let status = statuses + .until("flaky", |s| matches!(s, TunnelStatus::Retrying(_))) + .await; + assert_eq!( + status, + TunnelStatus::Retrying(String::from("connection lost")) + ); + assert!( + is_bound(local).await, + "the port is held while the link is down" + ); + + // At least one redial fails against the dead link before it comes back. + statuses + .until( + "flaky", + |s| matches!(s, TunnelStatus::Retrying(r) if r != "connection lost"), + ) + .await; + link.restore().await; + + statuses.until("flaky", |s| *s == TunnelStatus::Up).await; + assert_eq!(round_trip(local, "after").await, "after"); +} + +/// A refused login ends the tunnel instead of being retried. +#[tokio::test] +async fn a_rejected_password_fails_without_a_retry() { + isolate_home(); + let (server, _) = ssh_server().await; + let link = Link::to(server).await; + let local = free_port().await; + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host("typo", link.port, "wrong", vec![forward(local, 9)])); + + assert_eq!(statuses.next("typo").await, TunnelStatus::Connecting); + match statuses.next("typo").await { + TunnelStatus::Failed(reason) => { + assert!(reason.contains("authentication failed"), "{reason}") + } + other => panic!("expected Failed, got {other:?}"), + } + + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!(link.dials(), 1, "a refused login must not be retried"); + assert!(!tunnels.is_running("typo")); + assert!( + !is_bound(local).await, + "a failed tunnel lets go of its ports" + ); +} + +/// A link that dies during the login is a network fault, not a refusal: the +/// tunnel keeps dialling. +#[tokio::test] +async fn a_connection_lost_during_login_is_retried() { + isolate_home(); + let (server, _) = ssh_server().await; + let link = Link::to(server).await; + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "cut", + link.port, + DROPS_THE_LINE, + vec![forward(free_port().await, 9)], + )); + + assert_eq!(statuses.next("cut").await, TunnelStatus::Connecting); + for _ in 0..2 { + let status = statuses.next("cut").await; + assert!(matches!(status, TunnelStatus::Retrying(_)), "{status:?}"); + } + assert!(link.dials() >= 2); + assert!(tunnels.is_running("cut")); +} + +/// A server that turns the login down and hangs up is refusing it, not losing the +/// link — retrying is exactly what gets a client banned. +#[tokio::test] +async fn a_server_that_hangs_up_after_a_rejection_is_not_redialled() { + isolate_home(); + let (server, _) = ssh_server().await; + let link = Link::to(server).await; + let key = tempfile::NamedTempFile::new().expect("key file"); + russh::keys::encode_pkcs8_pem(&KeyPair::generate_ed25519(), key.as_file()).expect("write key"); + + let (mut tunnels, mut statuses) = Statuses::manager(); + let mut maxed = host( + "maxed", + link.port, + "wrong", + vec![forward(free_port().await, 9)], + ); + maxed.identity_file = Some(key.path().to_string_lossy().into_owned()); + tunnels.start(maxed); + + assert_eq!(statuses.next("maxed").await, TunnelStatus::Connecting); + let status = statuses.next("maxed").await; + assert!(matches!(status, TunnelStatus::Failed(_)), "{status:?}"); + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!(link.dials(), 1, "a refused login must not be retried"); +} + +/// A host key that no longer matches `known_hosts` is refused for good. +#[tokio::test] +async fn a_changed_host_key_fails_without_a_retry() { + isolate_home(); + let (server, _) = ssh_server().await; + let impostor = KeyPair::generate_ed25519() + .clone_public_key() + .expect("public key"); + russh::keys::known_hosts::learn_known_hosts("127.0.0.1", server.port(), &impostor) + .expect("record the old key"); + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "moved", + server.port(), + PASSWORD, + vec![forward(free_port().await, 9)], + )); + + match statuses + .until("moved", |s| !matches!(s, TunnelStatus::Connecting)) + .await + { + TunnelStatus::Failed(reason) => assert!(reason.contains("Unknown server key"), "{reason}"), + other => panic!("expected Failed, got {other:?}"), + } +} + +/// A port already taken fails the tunnel before any dial. +#[tokio::test] +async fn a_busy_port_fails_before_dialling() { + isolate_home(); + let (server, _) = ssh_server().await; + let link = Link::to(server).await; + let squatter = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let busy = squatter.local_addr().expect("addr").port(); + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host("clash", link.port, PASSWORD, vec![forward(busy, 9)])); + + match statuses.next("clash").await { + TunnelStatus::Failed(reason) => assert!(reason.contains(&busy.to_string()), "{reason}"), + other => panic!("expected Failed, got {other:?}"), + } + assert_eq!(link.dials(), 0); +} + +/// Stopping reports it and frees the ports at once. +#[tokio::test] +async fn stopping_releases_the_ports() { + isolate_home(); + let (server, _) = ssh_server().await; + let target = echo_service("").await; + let local = free_port().await; + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "once", + server.port(), + PASSWORD, + vec![forward(local, target)], + )); + statuses.until("once", |s| *s == TunnelStatus::Up).await; + + tunnels.stop("once"); + assert_eq!(statuses.next("once").await, TunnelStatus::Stopped); + assert!(!is_bound(local).await); + assert!(!tunnels.is_running("once")); +} + +/// A connection the server cannot open is closed and reported, while the tunnel +/// itself stays up for the forwards that work. +#[tokio::test] +async fn a_forward_the_server_cannot_open_is_reported() { + isolate_home(); + let (server, _) = ssh_server().await; + let (working, nothing_there) = (echo_service("").await, free_port().await); + let (good, bad) = (free_port().await, free_port().await); + + let (mut tunnels, mut statuses) = Statuses::manager(); + tunnels.start(host( + "half", + server.port(), + PASSWORD, + vec![forward(good, working), forward(bad, nothing_there)], + )); + statuses.until("half", |s| *s == TunnelStatus::Up).await; + + let mut socket = TcpStream::connect(("127.0.0.1", bad)) + .await + .expect("connect"); + let mut buf = [0u8; 16]; + let read = tokio::time::timeout(Duration::from_secs(10), socket.read(&mut buf)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "the local socket is closed" + ); + let error = statuses.error().await; + assert!( + error.contains("'half'") && error.contains(&bad.to_string()), + "{error}" + ); + + assert_eq!(round_trip(good, "still").await, "still"); + assert!(tunnels.is_running("half")); +} + +/// A start right after a stop takes the ports over from the stopping run instead +/// of racing it for them, and the statuses arrive in order. +#[tokio::test] +async fn a_start_right_after_a_stop_takes_the_ports_over() { + isolate_home(); + let (server, _) = ssh_server().await; + let target = echo_service("").await; + let local = free_port().await; + + let (mut tunnels, mut statuses) = Statuses::manager(); + let rule = host( + "again", + server.port(), + PASSWORD, + vec![forward(local, target)], + ); + tunnels.start(rule.clone()); + statuses.until("again", |s| *s == TunnelStatus::Up).await; + + tunnels.stop("again"); + assert!(!tunnels.is_running("again")); + tunnels.start(rule); + assert_eq!(statuses.next("again").await, TunnelStatus::Stopped); + assert_eq!(statuses.next("again").await, TunnelStatus::Connecting); + assert_eq!(statuses.next("again").await, TunnelStatus::Up); + assert_eq!(round_trip(local, "back").await, "back"); +} + +/// An edit restarts a running tunnel on the new rules; cosmetic edits and hosts +/// that were never started are left alone. +#[tokio::test] +async fn an_edit_restarts_only_the_tunnel_it_touches() { + isolate_home(); + let (server, _) = ssh_server().await; + let (old_target, new_target) = (echo_service("old:").await, echo_service("new:").await); + let (old_local, new_local) = (free_port().await, free_port().await); + + let (mut tunnels, mut statuses) = Statuses::manager(); + let before = host( + "svc", + server.port(), + PASSWORD, + vec![forward(old_local, old_target)], + ); + tunnels.start(before.clone()); + statuses.until("svc", |s| *s == TunnelStatus::Up).await; + + let mut cosmetic = before.clone(); + cosmetic.notes = Some(String::from("renamed the notes only")); + let idle = host( + "idle", + server.port(), + PASSWORD, + vec![forward(free_port().await, 9)], + ); + tunnels.sync(&[cosmetic, idle]); + assert!(!tunnels.is_running("idle"), "sync never starts a tunnel"); + assert_eq!(round_trip(old_local, "a").await, "old:a"); + + let mut edited = before.clone(); + edited.local_forwards = vec![forward(new_local, new_target)]; + tunnels.sync(&[edited]); + assert_eq!(statuses.next("svc").await, TunnelStatus::Stopped); + assert_eq!(statuses.next("svc").await, TunnelStatus::Connecting); + assert_eq!(statuses.next("svc").await, TunnelStatus::Up); + assert_eq!(round_trip(new_local, "b").await, "new:b"); + assert!( + !is_bound(old_local).await, + "the dropped rule's port is free" + ); + + // A host that is gone takes its tunnel with it. + tunnels.sync(&[]); + assert_eq!(statuses.next("svc").await, TunnelStatus::Stopped); +} diff --git a/crates/omnyssh-gui/src/bridge.rs b/crates/omnyssh-gui/src/bridge.rs index 6537267..eb0be5d 100644 --- a/crates/omnyssh-gui/src/bridge.rs +++ b/crates/omnyssh-gui/src/bridge.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, Manager}; use tauri_specta::Event; use tokio::sync::mpsc; -use crate::dto::{FileEntryDto, TransferProgressDto}; +use crate::dto::{FileEntryDto, TransferProgressDto, TunnelStatusDto}; use crate::events; use crate::state::GuiState; @@ -40,6 +40,17 @@ pub async fn forward_core_events(app: AppHandle, mut rx: mpsc::Receiver { let _ = events::ServicesFailed { host_name, message }.emit(&app); } + CoreEvent::TunnelStatusChanged(host_name, status) => { + let status = TunnelStatusDto::from(&status); + app.state::() + .tunnel_status_changed(&host_name, &status, || { + let _ = events::TunnelStatusChanged { + host_name: host_name.clone(), + status: status.clone(), + } + .emit(&app); + }); + } CoreEvent::Error(message) => { let _ = events::Error { message }.emit(&app); } diff --git a/crates/omnyssh-gui/src/commands/hosts.rs b/crates/omnyssh-gui/src/commands/hosts.rs index e05d5f3..03db430 100644 --- a/crates/omnyssh-gui/src/commands/hosts.rs +++ b/crates/omnyssh-gui/src/commands/hosts.rs @@ -4,7 +4,7 @@ use tauri_specta::Event; use omnyssh_core::config::{load_hosts, save_hosts}; use omnyssh_core::ssh::client::{Host, HostSource}; -use crate::dto::{HostDto, HostInputDto}; +use crate::dto::{check_forwards, HostDto, HostInputDto}; use crate::error::CommandError; use crate::events; use crate::state::GuiState; @@ -26,8 +26,10 @@ pub fn refresh_metrics(state: State<'_, GuiState>) -> Result<(), CommandError> { } /// Reload hosts from the shared config, refresh the cache, restart the pollers, -/// and broadcast the new list via `hosts-loaded` (tech-gui.md §4.2). Also the -/// startup entry point: the frontend calls it once its event bridge is up. +/// bring running tunnels in line with the edit, and broadcast the new list via +/// `hosts-loaded` followed by every live tunnel's status (tech-gui.md §4.2). Also +/// the startup entry point: the frontend calls it once its event bridge is up, +/// which is when tunnels autostart. #[tauri::command] #[specta::specta] pub async fn reload_hosts(app: AppHandle, state: State<'_, GuiState>) -> Result<(), CommandError> { @@ -52,7 +54,16 @@ pub async fn reload_hosts(app: AppHandle, state: State<'_, GuiState>) -> Result< })?; state.set_hosts(hosts); state.restart_pollers(); + state.sync_tunnels(); + state.autostart_tunnels(); let _ = events::HostsLoaded(state.host_dtos()).emit(&app); + state.replay_tunnel_statuses(|host_name, status| { + let _ = events::TunnelStatusChanged { + host_name: host_name.to_string(), + status: status.clone(), + } + .emit(&app); + }); Ok(()) } @@ -68,6 +79,7 @@ pub async fn save_host( input: HostInputDto, state: State<'_, GuiState>, ) -> Result<(), CommandError> { + check_forwards(&input.local_forwards).map_err(|message| CommandError { message })?; // The parsed import is the only record of its bastion and key path, and neither // crosses the boundary (§3.4), so read them off the cache before the write moves // to a blocking task. @@ -178,6 +190,8 @@ mod tests { notes: None, monitoring: None, monitor_port: None, + local_forwards: vec![], + tunnel_autostart: false, } } @@ -364,4 +378,19 @@ mod tests { assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].name, "a"); } + + #[test] + fn upsert_takes_forwards_and_autostart_from_the_form() { + // Both are on the form, so an edit replaces them outright — clearing the list + // must clear it, not fall back to the stored rules. + let mut hosts = vec![Host { + name: "nas".to_string(), + local_forwards: vec!["9443:localhost:9443".parse().expect("rule")], + tunnel_autostart: true, + ..Host::default() + }]; + upsert(&mut hosts, input("nas"), None); + assert!(hosts[0].local_forwards.is_empty()); + assert!(!hosts[0].tunnel_autostart); + } } diff --git a/crates/omnyssh-gui/src/commands/mod.rs b/crates/omnyssh-gui/src/commands/mod.rs index 04c67cc..bab7e0d 100644 --- a/crates/omnyssh-gui/src/commands/mod.rs +++ b/crates/omnyssh-gui/src/commands/mod.rs @@ -6,4 +6,5 @@ pub mod keysetup; pub mod sftp; pub mod snippets; pub mod terminal; +pub mod tunnels; pub mod update; diff --git a/crates/omnyssh-gui/src/commands/tunnels.rs b/crates/omnyssh-gui/src/commands/tunnels.rs new file mode 100644 index 0000000..959b3d6 --- /dev/null +++ b/crates/omnyssh-gui/src/commands/tunnels.rs @@ -0,0 +1,28 @@ +//! Port forwarding (tech-gui.md §4.2): start and stop a host's tunnel. Progress +//! arrives as `tunnel-status-changed` through the bridge. + +use tauri::State; + +use crate::error::CommandError; +use crate::state::GuiState; + +/// Start `hostName`'s tunnel, or restart it if one is running. Async because the +/// tunnel is spawned onto the Tauri runtime. +#[tauri::command] +#[specta::specta] +pub async fn tunnel_start( + host_name: String, + state: State<'_, GuiState>, +) -> Result<(), CommandError> { + state + .start_tunnel(&host_name) + .map_err(|message| CommandError { message }) +} + +/// Stop `hostName`'s tunnel. A no-op when none runs. +#[tauri::command] +#[specta::specta] +pub fn tunnel_stop(host_name: String, state: State<'_, GuiState>) -> Result<(), CommandError> { + state.stop_tunnel(&host_name); + Ok(()) +} diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index 35bb3cb..4c87ef3 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -12,6 +12,7 @@ use omnyssh_core::event::{ use omnyssh_core::ssh::client::{ConnectionStatus, Host, HostSource, MonitorMode}; use omnyssh_core::ssh::key_setup::KeySetupStep; use omnyssh_core::ssh::sftp::FileEntry; +use omnyssh_core::ssh::tunnel::{LocalForward, TunnelStatus}; use omnyssh_core::update::UpdateInfo; /// Host origin, mirrors `omnyssh_core::ssh::client::HostSource`. @@ -69,6 +70,33 @@ pub struct HostDto { pub monitoring: MonitorModeDto, #[serde(skip_serializing_if = "Option::is_none")] pub monitor_port: Option, + pub local_forwards: Vec, + pub tunnel_autostart: bool, +} + +/// One `ssh -L` rule (tech-gui.md §4.1): listen on `bindAddress:bindPort` here and +/// reach `remoteHost:remotePort` as the host resolves it. No `bindAddress` means the +/// loopback, as with ssh. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LocalForwardDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_address: Option, + pub bind_port: u16, + pub remote_host: String, + pub remote_port: u16, +} + +/// Where a host's tunnel stands (tech-gui.md §4.1). Internally tagged on `kind`, +/// like `ConnectionStatusDto`. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum TunnelStatusDto { + Connecting, + Up, + Retrying { message: String }, + Failed { message: String }, + Stopped, } /// Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always @@ -99,6 +127,8 @@ pub struct HostInputDto { pub monitoring: Option, #[serde(default)] pub monitor_port: Option, + pub local_forwards: Vec, + pub tunnel_autostart: bool, } /// Live connection state for a host (tech-gui.md §4.1). Internally tagged so the @@ -295,6 +325,59 @@ impl From<&Host> for HostDto { password_auth_disabled: host.password_auth_disabled, monitoring: host.monitoring.into(), monitor_port: host.monitor_port, + local_forwards: host.local_forwards.iter().map(Into::into).collect(), + tunnel_autostart: host.tunnel_autostart, + } + } +} + +impl From<&LocalForward> for LocalForwardDto { + fn from(forward: &LocalForward) -> Self { + Self { + bind_address: forward.bind_address.clone(), + bind_port: forward.bind_port, + remote_host: forward.remote_host.clone(), + remote_port: forward.remote_port, + } + } +} + +impl From for LocalForward { + fn from(dto: LocalForwardDto) -> Self { + Self { + bind_address: dto.bind_address, + bind_port: dto.bind_port, + remote_host: dto.remote_host, + remote_port: dto.remote_port, + } + } +} + +/// Rejects a rule `hosts.toml` could not read back. Rules are stored in the `ssh -L` +/// notation, so one that does not survive it — a zero port, an empty host — would be +/// saved, then dropped with a warning on the next load. +pub fn check_forwards(forwards: &[LocalForwardDto]) -> Result<(), String> { + for dto in forwards { + let forward = LocalForward::from(dto.clone()); + if forward.to_string().parse::().as_ref() != Ok(&forward) { + return Err(format!("'{forward}' is not a valid port forward")); + } + } + Ok(()) +} + +impl From<&TunnelStatus> for TunnelStatusDto { + fn from(status: &TunnelStatus) -> Self { + match status { + TunnelStatus::Connecting => Self::Connecting, + TunnelStatus::Up => Self::Up, + TunnelStatus::Retrying(message) => Self::Retrying { + message: message.clone(), + }, + TunnelStatus::Failed(message) => Self::Failed { + message: message.clone(), + }, + TunnelStatus::Stopped => Self::Stopped, } } } @@ -329,6 +412,8 @@ impl From for Host { monitor_port: dto .monitor_port .filter(|&p| p != 0 && monitoring == MonitorMode::TcpPort), + local_forwards: dto.local_forwards.into_iter().map(Into::into).collect(), + tunnel_autostart: dto.tunnel_autostart, key_setup_date: None, password_auth_disabled: None, } @@ -578,6 +663,8 @@ mod tests { notes: Some("primary".to_string()), monitoring: None, monitor_port: None, + local_forwards: vec![], + tunnel_autostart: false, } } @@ -631,6 +718,8 @@ mod tests { notes: Some(String::new()), monitoring: None, monitor_port: None, + local_forwards: vec![], + tunnel_autostart: false, }); assert!(host.identity_file.is_none()); assert!(host.password.is_none()); @@ -955,4 +1044,93 @@ mod tests { r#"{"sessionId":3,"transferId":7,"done":512,"total":2048}"# ); } + + fn rule(bind: Option<&str>, port: u16, host: &str, hostport: u16) -> LocalForwardDto { + LocalForwardDto { + bind_address: bind.map(str::to_string), + bind_port: port, + remote_host: host.to_string(), + remote_port: hostport, + } + } + + #[test] + fn host_dto_carries_forwards_and_autostart_but_still_no_secret() { + let mut host = host_with_secret(); + host.local_forwards = vec![ + "9443:127.0.0.1:9443".parse().expect("rule"), + "[::1]:8080:db:5432".parse().expect("rule"), + ]; + host.tunnel_autostart = true; + let json = serde_json::to_value(HostDto::from(&host)).expect("serialise HostDto"); + assert_eq!( + json["localForwards"], + serde_json::json!([ + {"bindPort": 9443, "remoteHost": "127.0.0.1", "remotePort": 9443}, + {"bindAddress": "::1", "bindPort": 8080, "remoteHost": "db", "remotePort": 5432}, + ]) + ); + assert_eq!(json["tunnelAutostart"], true); + let text = json.to_string(); + assert!( + !text.contains("s3cr3t") && !text.contains("id_ed25519"), + "{text}" + ); + } + + #[test] + fn host_input_forwards_reach_the_saved_host() { + let mut input = full_input(); + input.local_forwards = vec![rule(None, 5432, "localhost", 5432)]; + input.tunnel_autostart = true; + let host = Host::from(input); + assert_eq!(host.local_forwards[0].to_string(), "5432:localhost:5432"); + assert!(host.tunnel_autostart); + } + + #[test] + fn check_forwards_keeps_only_rules_hosts_toml_reads_back() { + assert!(check_forwards(&[ + rule(None, 9443, "127.0.0.1", 9443), + rule(Some("::1"), 8080, "fe80::1", 80), + rule(Some("*"), 8080, "web", 80), + rule(Some("0.0.0.0"), 8080, "web", 80), + ]) + .is_ok()); + for bad in [ + rule(None, 0, "web", 80), + rule(None, 8080, "web", 0), + rule(None, 8080, "", 80), + rule(None, 8080, "[web", 80), + ] { + assert!( + check_forwards(std::slice::from_ref(&bad)).is_err(), + "{bad:?}" + ); + } + } + + #[test] + fn tunnel_status_is_tagged_on_kind() { + let wire = |status: TunnelStatus| { + serde_json::to_value(TunnelStatusDto::from(&status)).expect("serialise") + }; + assert_eq!(wire(TunnelStatus::Up), serde_json::json!({"kind": "up"})); + assert_eq!( + wire(TunnelStatus::Connecting), + serde_json::json!({"kind": "connecting"}) + ); + assert_eq!( + wire(TunnelStatus::Stopped), + serde_json::json!({"kind": "stopped"}) + ); + assert_eq!( + wire(TunnelStatus::Retrying(String::from("connection lost"))), + serde_json::json!({"kind": "retrying", "message": "connection lost"}) + ); + assert_eq!( + wire(TunnelStatus::Failed(String::from("port 80 in use"))), + serde_json::json!({"kind": "failed", "message": "port 80 in use"}) + ); + } } diff --git a/crates/omnyssh-gui/src/events.rs b/crates/omnyssh-gui/src/events.rs index a842162..84cc22b 100644 --- a/crates/omnyssh-gui/src/events.rs +++ b/crates/omnyssh-gui/src/events.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::dto::{ ConnectionStatusDto, FileEntryDto, HostDto, KeySetupStepDto, MetricsDto, ServiceDto, - TransferProgressDto, UpdateInfoDto, + TransferProgressDto, TunnelStatusDto, UpdateInfoDto, }; /// Full host list broadcast. Emitted by `reload_hosts` after refreshing the @@ -47,6 +47,14 @@ pub struct ServicesFailed { pub message: String, } +/// A host's port-forwarding tunnel changed state (tech-gui.md §4.3). +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct TunnelStatusChanged { + pub host_name: String, + pub status: TunnelStatusDto, +} + /// Result of running a snippet on one host (tech-gui.md §4.3). Emitted directly by /// `execute_snippet` per host (one-shot `SshSession::run_command`), not via the /// shared bridge — the same "the command owns the result" pattern the SFTP diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 996304a..4acaf7e 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -21,6 +21,7 @@ use commands::sftp::{ }; use commands::snippets::{delete_snippet, execute_snippet, list_snippets, save_snippet}; use commands::terminal::{terminal_close, terminal_open, terminal_resize, terminal_write}; +use commands::tunnels::{tunnel_start, tunnel_stop}; use commands::update::{check_update, install_update, load_update_config, save_update_config}; use omnyssh_core::event::{CoreEvent, SessionId}; use omnyssh_core::ssh::pty::PtyManager; @@ -109,6 +110,8 @@ fn specta_builder() -> Builder { list_local_dir, preview_local_file, start_key_setup, + tunnel_start, + tunnel_stop, refresh_metrics, check_update, install_update, @@ -121,6 +124,7 @@ fn specta_builder() -> Builder { events::MetricsUpdated, events::ServicesDetected, events::ServicesFailed, + events::TunnelStatusChanged, events::SnippetResult, events::TerminalExited, events::SftpConnected, diff --git a/crates/omnyssh-gui/src/state.rs b/crates/omnyssh-gui/src/state.rs index 06d4c49..db75faf 100644 --- a/crates/omnyssh-gui/src/state.rs +++ b/crates/omnyssh-gui/src/state.rs @@ -13,10 +13,11 @@ use omnyssh_core::ssh::client::Host; use omnyssh_core::ssh::pool::PollManager; use omnyssh_core::ssh::pty::PtyManager; use omnyssh_core::ssh::sftp::{SftpCommand, SftpManager}; +use omnyssh_core::ssh::tunnel::TunnelManager; use tauri::ipc::Channel; use tokio::sync::mpsc; -use crate::dto::{HostDto, TerminalBytes}; +use crate::dto::{HostDto, TerminalBytes, TunnelStatusDto}; /// Metric poll cadence. Mirrors the TUI's fixed interval; a configurable refresh /// interval lands with settings in Stage 4.3 (tech-gui.md §4.3). @@ -94,6 +95,14 @@ pub struct GuiState { key_setup: Mutex>, /// Public id <-> inner handle mapping for all sessions. sessions: Mutex, + /// Every host's port-forwarding tunnel. Unlike the pollers it is never rebuilt on + /// reload — that would drop live tunnels on each save — only reconciled. + tunnels: Mutex, + /// One-shot latch so tunnels autostart once, on the first successful host load. + tunnels_autostarted: AtomicBool, + /// The last status of every tunnel that has not stopped, replayed to a + /// frontend that reloads while its tunnels keep running. + tunnel_statuses: Mutex>, /// Shared engine channel the bridge drains; cloned to `PollManager`/`PtyManager`. engine_tx: mpsc::Sender, } @@ -111,6 +120,9 @@ impl GuiState { update_check_started: AtomicBool::new(false), key_setup: Mutex::new(None), sessions: Mutex::new(SessionRegistry::default()), + tunnels: Mutex::new(TunnelManager::new(engine_tx.clone())), + tunnels_autostarted: AtomicBool::new(false), + tunnel_statuses: Mutex::new(HashMap::new()), engine_tx, } } @@ -209,6 +221,99 @@ impl GuiState { )); } + /// Start (or restart) `name`'s tunnel. Must run inside the Tauri async runtime — + /// the tunnel is a tokio task. + pub fn start_tunnel(&self, name: &str) -> Result<(), String> { + let host = self + .host_by_name(name) + .ok_or_else(|| format!("unknown host '{name}'"))?; + if host.local_forwards.is_empty() { + return Err(format!("'{name}' has no port forwards")); + } + self.tunnels + .lock() + .expect("tunnels lock poisoned") + .start(host); + Ok(()) + } + + /// Stop `name`'s tunnel; a no-op when none runs. + pub fn stop_tunnel(&self, name: &str) { + self.tunnels + .lock() + .expect("tunnels lock poisoned") + .stop(name); + } + + /// Apply a reloaded host list to the running tunnels: restart the ones whose + /// connection or rules changed, stop the ones whose host or rules are gone. + pub fn sync_tunnels(&self) { + let hosts = self.hosts.read().expect("hosts lock poisoned").clone(); + self.tunnels + .lock() + .expect("tunnels lock poisoned") + .sync(&hosts); + } + + /// Start every tunnel marked to start on launch — once, on the first call that + /// finds hosts loaded. Driven from `reload_hosts`, so the statuses reach a + /// frontend that is already listening (§3.4). + pub fn autostart_tunnels(&self) { + if self + .tunnels_autostarted + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + let hosts = self.hosts.read().expect("hosts lock poisoned").clone(); + self.tunnels + .lock() + .expect("tunnels lock poisoned") + .autostart(&hosts); + } + + /// Record a tunnel's status and `emit` it under one lock, so a replay can never + /// deliver an older status after a newer one. + pub fn tunnel_status_changed( + &self, + host_name: &str, + status: &TunnelStatusDto, + emit: impl FnOnce(), + ) { + let mut statuses = self + .tunnel_statuses + .lock() + .expect("tunnel_statuses lock poisoned"); + if matches!(status, TunnelStatusDto::Stopped) { + statuses.remove(host_name); + } else { + statuses.insert(host_name.to_string(), status.clone()); + } + emit(); + } + + /// `emit` the last status of every tunnel of a host still in the list — the + /// frontend's store is empty after a reload (§3.4). A status left by a host + /// that is gone is dropped here. + pub fn replay_tunnel_statuses(&self, mut emit: impl FnMut(&str, &TunnelStatusDto)) { + let names: Vec = self + .hosts + .read() + .expect("hosts lock poisoned") + .iter() + .map(|h| h.name.clone()) + .collect(); + let mut statuses = self + .tunnel_statuses + .lock() + .expect("tunnel_statuses lock poisoned"); + statuses.retain(|name, _| names.contains(name)); + for (name, status) in statuses.iter() { + emit(name, status); + } + } + /// Open a terminal for `host_name`, wiring its raw-output `channel`, and return /// the public session id. The session task starts here but cannot emit output /// until it connects, so registering the channel right after `open` beats the @@ -562,4 +667,88 @@ mod tests { let channel = Channel::new(|_| Ok(())); assert!(state.open_terminal("nope", 80, 24, channel).is_err()); } + + fn tunnel_host(name: &str, autostart: bool, forwards: &[&str]) -> Host { + Host { + name: name.to_string(), + // Nothing answers on port 1, so a started tunnel just keeps retrying. + hostname: "127.0.0.1".to_string(), + port: 1, + local_forwards: forwards.iter().map(|f| f.parse().expect("rule")).collect(), + tunnel_autostart: autostart, + ..Host::default() + } + } + + /// A loopback port nothing listens on right now. + fn free_port() -> u16 { + let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + probe.local_addr().expect("addr").port() + } + + #[tokio::test] + async fn start_tunnel_needs_a_known_host_with_forwards() { + let (engine_tx, _engine_rx) = mpsc::channel::(8); + let state = GuiState::new(engine_tx, PtyManager::new()); + state.set_hosts(vec![tunnel_host("bare", false, &[])]); + assert!(state + .start_tunnel("ghost") + .unwrap_err() + .contains("unknown host")); + assert!(state + .start_tunnel("bare") + .unwrap_err() + .contains("no port forwards")); + } + + #[tokio::test] + async fn tunnels_autostart_once_and_only_where_asked() { + let (engine_tx, _engine_rx) = mpsc::channel::(64); + let state = GuiState::new(engine_tx, PtyManager::new()); + let rule = |port: u16| format!("{port}:localhost:80"); + state.set_hosts(vec![ + tunnel_host("auto", true, &[&rule(free_port())]), + tunnel_host("manual", false, &[&rule(free_port())]), + tunnel_host("empty", true, &[]), + ]); + + state.autostart_tunnels(); + let running = |name| state.tunnels.lock().unwrap().is_running(name); + assert!(running("auto")); + assert!(!running("manual") && !running("empty")); + + // A later reload must not start what the user has since stopped. + state.stop_tunnel("auto"); + state.autostart_tunnels(); + assert!(!running("auto")); + } + + #[test] + fn a_reloaded_frontend_gets_the_live_tunnel_statuses_back() { + let (engine_tx, _engine_rx) = mpsc::channel::(8); + let state = GuiState::new(engine_tx, PtyManager::new()); + state.set_hosts(vec![ + tunnel_host("up", false, &[]), + tunnel_host("down", false, &[]), + ]); + + let mut emitted = 0; + let retrying = TunnelStatusDto::Retrying { + message: "connection lost".to_string(), + }; + state.tunnel_status_changed("up", &TunnelStatusDto::Up, || emitted += 1); + state.tunnel_status_changed("down", &retrying, || emitted += 1); + state.tunnel_status_changed("down", &TunnelStatusDto::Stopped, || emitted += 1); + state.tunnel_status_changed("gone", &TunnelStatusDto::Up, || emitted += 1); + assert_eq!(emitted, 4, "every change still goes out live"); + + // Only a tunnel that has not stopped, of a host still listed, comes back. + let mut replayed = Vec::new(); + state.replay_tunnel_statuses(|name, status| { + replayed.push((name.to_string(), status.clone())) + }); + assert_eq!(replayed.len(), 1); + assert_eq!(replayed[0].0, "up"); + assert!(matches!(replayed[0].1, TunnelStatusDto::Up)); + } } diff --git a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts index c8712f9..fbf7231 100644 --- a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts @@ -6,8 +6,8 @@ import { expect, test, type Page } from '@playwright/test'; // `hosts-loaded` event through the same listener the app registers — so a save/delete // round-trips into the dashboard grid exactly as the real backend would drive it. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true }, - { name: 'imported', hostname: 'imported.example.com', user: 'root', port: 22, tags: [], source: 'sshConfig', hasKey: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, + { name: 'imported', hostname: 'imported.example.com', user: 'root', port: 22, tags: [], source: 'sshConfig', hasKey: false, localForwards: [], tunnelAutostart: false } ]; async function boot(page: Page): Promise { @@ -46,7 +46,9 @@ async function boot(page: Page): Promise { tags: (h.tags as string[]) ?? [], notes: h.notes, source: 'manual', - hasKey: !!h.identityFile + hasKey: !!h.identityFile, + localForwards: h.localForwards, + tunnelAutostart: h.tunnelAutostart }; const i = state.hosts.findIndex((x) => (x as { name: string }).name === view.name); if (i >= 0) state.hosts[i] = { ...state.hosts[i], ...view }; diff --git a/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts b/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts index eaad1ac..6d35934 100644 --- a/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; // the host's hasKey/passwordAuthDisabled so the follow-up `reload_hosts` replays a keyed // host — exactly how the real backend drives the panel and refreshes the card. const HOSTS = [ - { name: 'pw-host', hostname: 'pw.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false } + { name: 'pw-host', hostname: 'pw.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/palette.spec.ts b/crates/omnyssh-gui/ui/e2e/palette.spec.ts index 0a38377..9da9751 100644 --- a/crates/omnyssh-gui/ui/e2e/palette.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/palette.spec.ts @@ -11,7 +11,9 @@ const HOSTS = [ port: 22, tags: ['prod'], source: 'manual', - hasKey: true + hasKey: true, + localForwards: [], + tunnelAutostart: false }, { name: 'db-1', @@ -20,7 +22,9 @@ const HOSTS = [ port: 22, tags: [], source: 'manual', - hasKey: false + hasKey: false, + localForwards: [], + tunnelAutostart: false } ]; diff --git a/crates/omnyssh-gui/ui/e2e/settings.spec.ts b/crates/omnyssh-gui/ui/e2e/settings.spec.ts index 2beca02..c3044f7 100644 --- a/crates/omnyssh-gui/ui/e2e/settings.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/settings.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; // is fired after `reload_hosts` (which the layout calls once its listeners are attached), // mirroring the startup check. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false } ]; const UPDATE = { diff --git a/crates/omnyssh-gui/ui/e2e/sftp.spec.ts b/crates/omnyssh-gui/ui/e2e/sftp.spec.ts index 3f0b0da..9fbfcc9 100644 --- a/crates/omnyssh-gui/ui/e2e/sftp.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/sftp.spec.ts @@ -8,8 +8,8 @@ import { expect, test, type Page } from '@playwright/test'; // the live progress bar is deterministically observable. Both spawn paths (a card's // `files`, and the SFTP spawner via the host picker) are load-bearing for the stage. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts index 9071fe7..a626cf8 100644 --- a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts @@ -6,8 +6,8 @@ import { expect, test, type Page } from '@playwright/test'; // back (so CRUD round-trips are observable), and `execute_snippet` delivers a // `snippet-result` event per host through the same listener the app registers. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } ]; const SNIPPETS = [ diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index 133f1b6..bd77c85 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -7,8 +7,8 @@ import { expect, test, type Page } from '@playwright/test'; // on Enter (proving input round-trips). The host-first path (a Dashboard card's `sh`, // no picker) is the load-bearing flow the stage requires. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts b/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts new file mode 100644 index 0000000..d4664ce --- /dev/null +++ b/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Port forwarding (issue #104). Tauri is stubbed at `__TAURI_INTERNALS__` (§6.4): the +// stub keeps a host list, records every command it gets, and answers `tunnel_start`/ +// `tunnel_stop` with the `tunnel-status-changed` events the backend would emit. +const BASE = { + user: 'admin', + port: 22, + tags: [], + source: 'manual', + hasKey: true, + monitoring: 'ssh', + localForwards: [], + tunnelAutostart: false +}; +const HOSTS = [ + { + ...BASE, + name: 'nas', + hostname: '10.0.0.5', + localForwards: [ + { bindPort: 9443, remoteHost: '127.0.0.1', remotePort: 9443 }, + { bindPort: 5432, remoteHost: 'db.internal', remotePort: 5432 } + ] + }, + { ...BASE, name: 'web-1', hostname: 'web-1.example.com' } +]; + +type Call = { cmd: string; args: Record }; + +async function boot(page: Page, options: { rejectStart?: string } = {}): Promise { + await page.addInitScript( + ({ hosts, rejectStart }) => { + let cbid = 0; + const listeners: Record = {}; + const state = { hosts: hosts.map((h) => ({ ...h })) as Array> }; + const win = window as unknown as Record; + const calls: Call[] = []; + win.__calls = calls; + + function fire(event: string, payload: unknown): void { + for (const id of listeners[event] ?? []) { + const cb = win[`__cb${id}`] as ((e: unknown) => void) | undefined; + cb?.({ event, id, payload }); + } + } + win.__fire = fire; + const tunnel = (hostName: string, ...statuses: unknown[]) => + statuses.forEach((status, i) => setTimeout(() => fire('tunnel-status-changed', { hostName, status }), 10 * (i + 1))); + + (win as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { + invoke: (cmd: string, args: Record) => { + calls.push({ cmd, args }); + switch (cmd) { + case 'list_hosts': + return Promise.resolve([...state.hosts]); + case 'reload_hosts': + setTimeout(() => fire('hosts-loaded', [...state.hosts]), 0); + return Promise.resolve(null); + case 'save_host': { + const h = args.input as Record & { name: string }; + const i = state.hosts.findIndex((x) => x.name === h.name); + const view = { + ...state.hosts[i], + hostname: h.hostname, + localForwards: h.localForwards, + tunnelAutostart: h.tunnelAutostart + }; + state.hosts[i] = view; + return Promise.resolve(null); + } + case 'tunnel_start': + if (rejectStart) return Promise.reject({ message: rejectStart }); + tunnel(args.hostName as string, { kind: 'connecting' }, { kind: 'up' }); + return Promise.resolve(null); + case 'tunnel_stop': + tunnel(args.hostName as string, { kind: 'stopped' }); + return Promise.resolve(null); + case 'plugin:event|listen': { + const { event, handler } = args as { event: string; handler: number }; + (listeners[event] ||= []).push(handler); + return Promise.resolve(cbid); + } + default: + return Promise.resolve(null); + } + }, + transformCallback: (cb: unknown) => { + const id = ++cbid; + win[`__cb${id}`] = cb; + return id; + } + }; + }, + { hosts: HOSTS, rejectStart: options.rejectStart } + ); + await page.goto('/'); + await expect(page.getByText('nas', { exact: true })).toBeVisible(); +} + +const calls = (page: Page): Promise => page.evaluate(() => (window as unknown as { __calls: Call[] }).__calls); + +test('starts and stops a tunnel from its card', async ({ page }) => { + await boot(page); + + // Both forwards are listed; a host without forwards has no tunnel block at all. + await expect(page.getByText('localhost:9443')).toBeVisible(); + await expect(page.getByText('db.internal:5432')).toBeVisible(); + await expect(page.getByRole('button', { name: /tunnel to web-1/ })).toHaveCount(0); + + const toggle = page.getByRole('button', { name: 'Start the tunnel to nas' }); + await toggle.click(); + await expect(page.getByText('Active', { exact: true })).toBeVisible(); + expect((await calls(page)).filter((c) => c.cmd === 'tunnel_start')).toEqual([ + { cmd: 'tunnel_start', args: { hostName: 'nas' } } + ]); + + // A drop shows why, while the tunnel keeps trying on its own. + await page.evaluate(() => + (window as unknown as { __fire: (e: string, p: unknown) => void }).__fire('tunnel-status-changed', { + hostName: 'nas', + status: { kind: 'retrying', message: 'connection lost' } + }) + ); + await expect(page.getByText('Reconnecting…')).toBeVisible(); + await expect(page.getByText('connection lost')).toBeVisible(); + + await page.getByRole('button', { name: 'Stop the tunnel to nas' }).click(); + await expect(page.getByText('Off', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Start the tunnel to nas' })).toBeVisible(); +}); + +test('a start the backend refuses surfaces its reason', async ({ page }) => { + await boot(page, { rejectStart: "'nas' has no port forwards" }); + await page.getByRole('button', { name: 'Start the tunnel to nas' }).click(); + await expect(page.getByText("'nas' has no port forwards")).toBeVisible(); +}); + +test('adds a forward in the editor and the card picks it up', async ({ page }) => { + await boot(page); + + await page.getByRole('button', { name: 'Edit web-1' }).click(); + const editor = page.getByRole('dialog', { name: 'Edit host' }); + await editor.getByRole('button', { name: 'Add forward' }).click(); + await editor.getByLabel('Forward 1 local port').fill('8080'); + await editor.getByLabel('Forward 1 remote host').fill('127.0.0.1'); + await editor.getByLabel('Forward 1 remote port').fill('80'); + await editor.getByRole('switch', { name: 'Start tunnel when OmnySSH opens' }).click(); + await editor.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + + const saved = (await calls(page)).find((c) => c.cmd === 'save_host')?.args.input as Record; + expect(saved.localForwards).toEqual([{ bindPort: 8080, remoteHost: '127.0.0.1', remotePort: 80 }]); + expect(saved.tunnelAutostart).toBe(true); + + await expect(page.getByText('localhost:8080')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Start the tunnel to web-1' })).toBeVisible(); + await expect(page.getByTitle('Starts when OmnySSH opens')).toBeVisible(); +}); + +test('a half-filled forward keeps the editor open with the reason', async ({ page }) => { + await boot(page); + + await page.getByRole('button', { name: 'Edit web-1' }).click(); + const editor = page.getByRole('dialog', { name: 'Edit host' }); + await editor.getByRole('button', { name: 'Add forward' }).click(); + await editor.getByLabel('Forward 1 local port').fill('8080'); + await editor.getByRole('button', { name: 'Save' }).click(); + + await expect(editor.getByText('Forward 1: remote port must be a number')).toBeVisible(); + expect((await calls(page)).some((c) => c.cmd === 'save_host')).toBe(false); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index f9315d1..5ba7306 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -18,8 +18,10 @@ async listHosts() : Promise> { }, /** * Reload hosts from the shared config, refresh the cache, restart the pollers, - * and broadcast the new list via `hosts-loaded` (tech-gui.md §4.2). Also the - * startup entry point: the frontend calls it once its event bridge is up. + * bring running tunnels in line with the edit, and broadcast the new list via + * `hosts-loaded` followed by every live tunnel's status (tech-gui.md §4.2). Also + * the startup entry point: the frontend calls it once its event bridge is up, + * which is when tunnels autostart. */ async reloadHosts() : Promise> { try { @@ -297,6 +299,29 @@ async startKeySetup(hostName: string) : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Start `hostName`'s tunnel, or restart it if one is running. Async because the + * tunnel is spawned onto the Tauri runtime. + */ +async tunnelStart(hostName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("tunnel_start", { hostName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Stop `hostName`'s tunnel. A no-op when none runs. + */ +async tunnelStop(hostName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("tunnel_stop", { hostName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Trigger an immediate metric poll of every host (tech-gui.md §4.2). Used by the * settings-driven refresh cadence (§4.3); a no-op before the pollers start. @@ -382,6 +407,7 @@ sftpOpDone: SftpOpDone, snippetResult: SnippetResult, terminalExited: TerminalExited, transferProgress: TransferProgress, +tunnelStatusChanged: TunnelStatusChanged, updateAvailable: UpdateAvailable }>({ error: "error", @@ -402,6 +428,7 @@ sftpOpDone: "sftp-op-done", snippetResult: "snippet-result", terminalExited: "terminal-exited", transferProgress: "transfer-progress", +tunnelStatusChanged: "tunnel-status-changed", updateAvailable: "update-available" }) @@ -436,7 +463,7 @@ export type FilePreview = { sessionId: number; path: string; content: string } * (tech-gui.md §3.4). `hasKey` reports whether an identity file is configured; * the key path itself never crosses the boundary. */ -export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null } +export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean } /** * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always * builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows @@ -445,7 +472,7 @@ export type HostDto = { name: string; hostname: string; user: string; port: numb * travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it * derives `Deserialize` (not `Serialize`). */ -export type HostInputDto = { name: string; hostname: string; user: string; port: number; identityFile?: string | null; password?: string | null; proxyJump?: string | null; tags: string[]; notes?: string | null; monitoring?: MonitorModeDto | null; monitorPort?: number | null } +export type HostInputDto = { name: string; hostname: string; user: string; port: number; identityFile?: string | null; password?: string | null; proxyJump?: string | null; tags: string[]; notes?: string | null; monitoring?: MonitorModeDto | null; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean } /** * Host origin, mirrors `omnyssh_core::ssh::client::HostSource`. */ @@ -485,6 +512,12 @@ export type KeySetupRollback = { hostName: string; result: string } * Maps from the core `KeySetupStep`. */ export type KeySetupStepDto = { index: number; total: number; description: string } +/** + * One `ssh -L` rule (tech-gui.md §4.1): listen on `bindAddress:bindPort` here and + * reach `remoteHost:remotePort` as the host resolves it. No `bindAddress` means the + * loopback, as with ssh. + */ +export type LocalForwardDto = { bindAddress?: string | null; bindPort: number; remoteHost: string; remotePort: number } /** * A metrics snapshot for a host (tech-gui.md §4.1). The core's `Instant` is * flattened to `ageSeconds` (seconds since the sample) so it can serialise. @@ -594,6 +627,15 @@ export type TransferProgress = TransferProgressDto * remote size could not be determined). */ export type TransferProgressDto = { sessionId: number; transferId: number; done: number; total: number } +/** + * A host's port-forwarding tunnel changed state (tech-gui.md §4.3). + */ +export type TunnelStatusChanged = { hostName: string; status: TunnelStatusDto } +/** + * Where a host's tunnel stands (tech-gui.md §4.1). Internally tagged on `kind`, + * like `ConnectionStatusDto`. + */ +export type TunnelStatusDto = { kind: "connecting" } | { kind: "up" } | { kind: "retrying"; message: string } | { kind: "failed"; message: string } | { kind: "stopped" } /** * A newer release was found by the startup check (tech-gui.md §4.3). Mapped by the * shared engine bridge from `CoreEvent::UpdateAvailable`; drives the update banner. diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts index f49bf08..8f0af5f 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts @@ -178,6 +178,19 @@ export async function startKeySetup(hostName: string): Promise { if (res.status === 'error') throw new Error(res.error.message); } +/** Start (or restart) a host's tunnel; its progress arrives as `tunnel-status-changed` + * (tech-gui.md §4.2). Rejects an unknown host or one without port forwards. */ +export async function tunnelStart(hostName: string): Promise { + const res = await commands.tunnelStart(hostName); + if (res.status === 'error') throw new Error(res.error.message); +} + +/** Stop a host's tunnel; a no-op when none runs. */ +export async function tunnelStop(hostName: string): Promise { + const res = await commands.tunnelStop(hostName); + if (res.status === 'error') throw new Error(res.error.message); +} + /** Force an immediate metric poll of every host (tech-gui.md §4.2). */ export async function refreshMetrics(): Promise { const res = await commands.refreshMetrics(); diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts index 526dabf..563b93f 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts @@ -5,6 +5,7 @@ import { hosts } from '$lib/stores/hosts'; import { statuses } from '$lib/stores/statuses'; import { metrics } from '$lib/stores/metrics'; import { services } from '$lib/stores/services'; +import { tunnels } from '$lib/stores/tunnels'; import { snippetRun, beginRun, clearRun } from '$lib/stores/snippets'; import { sessions } from '$lib/stores/sessions'; import { lastError } from '$lib/stores/notifications'; @@ -22,6 +23,7 @@ import { applyServicesFailed, applySnippetResult, applyTerminalExited, + applyTunnelStatusChanged, terminalDidExit } from './router'; @@ -31,6 +33,7 @@ describe('ipc event router', () => { statuses.set(new Map()); metrics.set(new Map()); services.set(new Map()); + tunnels.set(new Map()); lastError.set(null); }); @@ -44,7 +47,9 @@ describe('ipc event router', () => { tags: [], source: 'manual', hasKey: false, - monitoring: 'ssh' + monitoring: 'ssh', + localForwards: [], + tunnelAutostart: false } ]; @@ -111,7 +116,7 @@ describe('ipc event router', () => { applyServicesDetected({ hostName: 'web-2', services: [{ kind: 'docker', metrics: [] }] }); applyHostsLoaded([ - { name: 'web-1', hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh' } + { name: 'web-1', hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false } ]); expect(get(statuses).has('web-2')).toBe(false); @@ -201,4 +206,21 @@ describe('ipc event router', () => { expect(get(keySetup)).toEqual({ hostName: 'db-1', phase: { kind: 'rolledBack', result: 'Restored.' } }); dismissKeySetup(); }); + + it('keeps each host\'s latest tunnel status and forgets a stopped one', () => { + applyTunnelStatusChanged({ hostName: 'nas', status: { kind: 'connecting' } }); + applyTunnelStatusChanged({ hostName: 'nas', status: { kind: 'up' } }); + applyTunnelStatusChanged({ hostName: 'db', status: { kind: 'retrying', message: 'connection lost' } }); + expect(get(tunnels).get('nas')).toEqual({ kind: 'up' }); + expect(get(tunnels).get('db')).toEqual({ kind: 'retrying', message: 'connection lost' }); + + applyTunnelStatusChanged({ hostName: 'nas', status: { kind: 'stopped' } }); + expect(get(tunnels).has('nas')).toBe(false); + }); + + it('drops the tunnel status of a host that is gone', () => { + applyTunnelStatusChanged({ hostName: 'old', status: { kind: 'failed', message: 'x' } }); + applyHostsLoaded([]); + expect(get(tunnels).has('old')).toBe(false); + }); }); diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/router.ts b/crates/omnyssh-gui/ui/src/lib/ipc/router.ts index 360863c..1a0f841 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/router.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/router.ts @@ -17,12 +17,14 @@ import type { SftpDisconnected, SftpOpDone, SnippetResult, - TransferProgressDto + TransferProgressDto, + TunnelStatusChanged } from '$lib/bindings'; import { hosts } from '$lib/stores/hosts'; import { statuses } from '$lib/stores/statuses'; import { metrics, mergeMetrics } from '$lib/stores/metrics'; import { services } from '$lib/stores/services'; +import { tunnels } from '$lib/stores/tunnels'; import { snippetRun, reduceRunResult } from '$lib/stores/snippets'; import { sessions } from '$lib/stores/sessions'; import { sftp } from '$lib/stores/sftp'; @@ -47,6 +49,7 @@ export function applyHostsLoaded(payload: HostDto[]): void { statuses.update(prune); metrics.update(prune); services.update(prune); + tunnels.update(prune); } export function applyHostStatusChanged(payload: { @@ -70,6 +73,17 @@ export function applyServicesFailed(payload: { hostName: string; message: string services.update((m) => new Map(m).set(payload.hostName, { kind: 'failed', message: payload.message })); } +// A stopped tunnel leaves no entry, so a host that is renamed or deleted while its +// tunnel winds down does not keep a stale one. +export function applyTunnelStatusChanged(payload: TunnelStatusChanged): void { + tunnels.update((m) => { + const next = new Map(m); + if (payload.status.kind === 'stopped') next.delete(payload.hostName); + else next.set(payload.hostName, payload.status); + return next; + }); +} + export function applySnippetResult(payload: SnippetResult): void { snippetRun.update((run) => reduceRunResult(run, payload)); } diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.test.ts b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.test.ts index db408a9..a3b48c6 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.test.ts @@ -21,6 +21,7 @@ vi.mock('$lib/bindings', () => { metricsUpdated: channel('metricsUpdated'), servicesDetected: channel('servicesDetected'), servicesFailed: channel('servicesFailed'), + tunnelStatusChanged: channel('tunnelStatusChanged'), snippetResult: channel('snippetResult'), terminalExited: channel('terminalExited'), sftpConnected: channel('sftpConnected'), diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts index ca69cc3..748c7dc 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts @@ -23,7 +23,8 @@ import { applySftpOpDone, applySnippetResult, applyTerminalExited, - applyTransferProgress + applyTransferProgress, + applyTunnelStatusChanged } from './router'; export async function startEventBridge(): Promise<() => void> { @@ -34,6 +35,7 @@ export async function startEventBridge(): Promise<() => void> { offs.push(await events.metricsUpdated.listen((e) => applyMetricsUpdated(e.payload))); offs.push(await events.servicesDetected.listen((e) => applyServicesDetected(e.payload))); offs.push(await events.servicesFailed.listen((e) => applyServicesFailed(e.payload))); + offs.push(await events.tunnelStatusChanged.listen((e) => applyTunnelStatusChanged(e.payload))); offs.push(await events.snippetResult.listen((e) => applySnippetResult(e.payload))); offs.push(await events.terminalExited.listen((e) => applyTerminalExited(e.payload.sessionId))); offs.push(await events.sftpConnected.listen((e) => applySftpConnected(e.payload))); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte index a8012cf..1569c6c 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte @@ -9,12 +9,20 @@ import { get } from 'svelte/store'; import type { HostDto, HostInputDto } from '$lib/bindings'; import { Surface, Chip, StatusDot, Icon, Button, statusToken } from '$lib/theme'; - import { serverCards, filterHosts, QUICK_ACTIONS } from './serverCard'; + import { serverCards, filterHosts, forwardListen, forwardTarget, QUICK_ACTIONS } from './serverCard'; import { spawnSession } from '$lib/stores/navigation'; import { streamerMode, displayHostname } from '$lib/stores/streamer'; import { hosts } from '$lib/stores/hosts'; import { lastError } from '$lib/stores/notifications'; - import { saveHost, deleteHost, reloadHosts, startKeySetup, refreshMetrics } from '$lib/ipc/commands'; + import { + saveHost, + deleteHost, + reloadHosts, + startKeySetup, + refreshMetrics, + tunnelStart, + tunnelStop + } from '$lib/ipc/commands'; import { isRefreshHotkey } from '$lib/stores/ui'; import { beginKeySetup, dismissKeySetup } from '$lib/stores/keySetup'; import { emptyForm, formFromHost } from './hostForm'; @@ -94,6 +102,15 @@ } } + // The outcome arrives as `tunnel-status-changed`; only a rejected command lands here. + async function toggleTunnel(name: string, running: boolean): Promise { + try { + await (running ? tunnelStop(name) : tunnelStart(name)); + } catch (e) { + lastError.set(message(e)); + } + } + async function confirmDelete(name: string): Promise { try { await deleteHost(name); @@ -353,6 +370,51 @@ {:else if card.servicesError}
Service scan unavailable
{/if} + + + {#if card.tunnel} + {@const tunnel = card.tunnel} +
+
+ + + {tunnel.label} + {#if tunnel.autostart} + + auto + + {/if} + +
+
    + {#each tunnel.forwards as forward, f (f)} +
  • + {forwardListen(forward, $streamerMode)} + → + {forwardTarget(forward, $streamerMode)} +
  • + {/each} +
+ + {#if tunnel.message} +

+ {$streamerMode ? 'Details hidden in streamer mode' : tunnel.message} +

+ {/if} +
+ {/if} {/each} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte index f5f23ec..bc74d22 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte @@ -5,10 +5,10 @@ // surfaces inline without closing. Semantic tokens only. import { onMount } from 'svelte'; import type { HostInputDto } from '$lib/bindings'; - import { Button } from '$lib/theme'; + import { Button, Icon } from '$lib/theme'; import Modal from '$lib/components/Modal.svelte'; import Select from '$lib/components/Select.svelte'; - import { formToInput, type HostFormFields } from './hostForm'; + import { emptyForwardRow, formToInput, type HostFormFields } from './hostForm'; let { mode, @@ -63,6 +63,11 @@ const secretHint = $derived(mode === 'edit' ? 'Leave blank to keep the current value' : undefined); const label = 'block space-y-1 text-xs font-medium text-muted'; + const forwardGrid = 'grid grid-cols-[8.5rem,1fr,4.5rem,1.75rem] items-center gap-2'; + const smallBtn = + 'inline-flex items-center gap-1 rounded-full border border-default px-2 py-0.5 text-xs text-muted transition ' + + 'hover:border-strong hover:bg-accent hover:text-accent-fg ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus'; const field = 'w-full rounded-lg bg-surface-inset px-3 py-2 text-sm text-fg outline-none ' + 'focus-visible:ring-2 focus-visible:ring-focus placeholder:text-faint'; @@ -170,6 +175,91 @@

Checks the port only — no login, and no metrics on the card.

{/if} + +
+
+ Port forwarding + +
+ {#if fields.forwards.length} +
+ Local port + Remote host + Port + +
+ {#each fields.forwards as row, i (i)} +
+ + + + +
+ {/each} +

+ The local port listens on this machine only — write 0.0.0.0:8080 + to share it on your network. The remote host is resolved by the server, so + localhost is the server itself. +

+
+ Start tunnel when OmnySSH opens + +
+ {:else} +

+ Reach a service on this server — a database, a web UI — at a port on this machine, + like ssh -L. +

+ {/if} +
+ {#if error}

{error}

{/if} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts index b26a61a..42c8f70 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { HostDto } from '$lib/bindings'; -import { emptyForm, formFromHost, formToInput, type HostFormFields } from './hostForm'; +import { emptyForm, emptyForwardRow, formFromHost, formToInput, type HostFormFields } from './hostForm'; function fields(partial: Partial): HostFormFields { return { ...emptyForm(), ...partial }; @@ -16,6 +16,8 @@ function host(partial: Partial): HostDto { source: 'manual', hasKey: false, monitoring: 'ssh', + localForwards: [], + tunnelAutostart: false, ...partial }; } @@ -127,6 +129,8 @@ describe('formFromHost', () => { tags: ['ops'], notes: 'x', monitoring: 'ssh', + localForwards: [], + tunnelAutostart: false, monitorPort: undefined }); }); @@ -194,3 +198,75 @@ describe('formToInput — monitoring mode', () => { expect(fields.monitorPort).toBe('8443'); }); }); + +describe('formToInput — port forwarding', () => { + const base = { name: 'nas', hostname: '10.0.0.5' }; + const row = (local: string, remoteHost: string, remotePort: string) => ({ local, remoteHost, remotePort }); + + it('reads a port, an address and a bracketed IPv6 address on the local side', () => { + const r = formToInput( + fields({ + ...base, + forwards: [ + row('9443', 'localhost', '9443'), + row('0.0.0.0:8080', 'web.internal', '80'), + row('[::1]:5432', '[fe80::1]', '5432') + ] + }) + ); + expect(r.ok && r.input.localForwards).toEqual([ + { bindAddress: undefined, bindPort: 9443, remoteHost: 'localhost', remotePort: 9443 }, + { bindAddress: '0.0.0.0', bindPort: 8080, remoteHost: 'web.internal', remotePort: 80 }, + { bindAddress: '::1', bindPort: 5432, remoteHost: 'fe80::1', remotePort: 5432 } + ]); + }); + + it('drops a row left blank', () => { + const r = formToInput(fields({ ...base, forwards: [emptyForwardRow(), row('3000', 'localhost', '3000')] })); + expect(r.ok && r.input.localForwards).toHaveLength(1); + }); + + it('names the row and the problem', () => { + const cases: [ReturnType, string][] = [ + [row('0', 'localhost', '80'), 'Forward 1: local port'], + [row('::1:8080', 'localhost', '80'), 'Forward 1: put an IPv6 address in brackets'], + [row('8080', ' ', '80'), 'Forward 1: enter the host'], + [row('8080', 'local host', '80'), 'Forward 1: enter the host'], + [row('8080', 'localhost', '70000'), 'Forward 1: remote port'] + ]; + for (const [bad, message] of cases) { + const r = formToInput(fields({ ...base, forwards: [bad] })); + expect(r.ok ? '' : r.error).toContain(message); + } + }); + + it('refuses two forwards on one local port, however it is written', () => { + const r = formToInput( + fields({ ...base, forwards: [row('9443', 'localhost', '1'), row('localhost:9443', 'localhost', '2')] }) + ); + expect(r).toEqual({ ok: false, error: 'Forward 2 listens on the same port as forward 1' }); + }); + + it('drops the autostart flag along with the last forward', () => { + const r = formToInput(fields({ ...base, forwards: [], tunnelAutostart: true })); + expect(r.ok && r.input.tunnelAutostart).toBe(false); + }); + + it('round-trips forwards and autostart through the edit form', () => { + const original = host({ + localForwards: [ + { bindPort: 9443, remoteHost: '127.0.0.1', remotePort: 9443 }, + { bindAddress: '::1', bindPort: 8080, remoteHost: 'db', remotePort: 5432 } + ], + tunnelAutostart: true + }); + const f = formFromHost(original); + expect(f.forwards.map((r) => r.local)).toEqual(['9443', '[::1]:8080']); + const r = formToInput(f); + expect(r.ok && r.input.tunnelAutostart).toBe(true); + expect(r.ok && r.input.localForwards).toEqual([ + { bindAddress: undefined, bindPort: 9443, remoteHost: '127.0.0.1', remotePort: 9443 }, + { bindAddress: '::1', bindPort: 8080, remoteHost: 'db', remotePort: 5432 } + ]); + }); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts index 7f32567..6df2e6b 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts @@ -3,7 +3,15 @@ // validation mirrors the TUI's `HostForm::to_host` (crates/omnyssh/src/app/host.rs) // so both frontends produce the same `hosts.toml` shape and error messages. -import type { HostDto, HostInputDto, MonitorModeDto } from '$lib/bindings'; +import type { HostDto, HostInputDto, LocalForwardDto, MonitorModeDto } from '$lib/bindings'; + +/** One port-forward row as typed: listen on `[address:]port` here, reach + * `remoteHost:remotePort` from the host. */ +export interface ForwardRow { + local: string; + remoteHost: string; + remotePort: string; +} /** The editable form fields — all raw text (tags are comma-separated, port a string). */ export interface HostFormFields { @@ -18,6 +26,8 @@ export interface HostFormFields { monitoring: MonitorModeDto; /** Probe port; blank means "the host's SSH port". Only read for `tcpPort`. */ monitorPort: string; + forwards: ForwardRow[]; + tunnelAutostart: boolean; } export function emptyForm(): HostFormFields { @@ -33,7 +43,24 @@ export function emptyForm(): HostFormFields { tags: '', notes: '', monitoring: 'ssh', - monitorPort: '' + monitorPort: '', + forwards: [], + tunnelAutostart: false + }; +} + +export function emptyForwardRow(): ForwardRow { + return { local: '', remoteHost: 'localhost', remotePort: '' }; +} + +/** An IPv6 address needs brackets to survive the colon-separated notation. */ +export const bracketed = (address: string): string => (address.includes(':') ? `[${address}]` : address); + +function rowFromForward(f: LocalForwardDto): ForwardRow { + return { + local: f.bindAddress == null ? String(f.bindPort) : `${bracketed(f.bindAddress)}:${f.bindPort}`, + remoteHost: f.remoteHost, + remotePort: String(f.remotePort) }; } @@ -51,10 +78,63 @@ export function formFromHost(h: HostDto): HostFormFields { tags: h.tags.join(', '), notes: h.notes ?? '', monitoring: h.monitoring, - monitorPort: h.monitorPort == null ? '' : String(h.monitorPort) + monitorPort: h.monitorPort == null ? '' : String(h.monitorPort), + forwards: h.localForwards.map(rowFromForward), + tunnelAutostart: h.tunnelAutostart }; } +// Digits with an optional leading `+`, matching Rust's `u16::parse`; the range check +// covers 0 and overflow. +function parsePort(raw: string): number | undefined { + const v = raw.trim(); + if (!/^\+?\d+$/.test(v) || Number(v) < 1 || Number(v) > 65535) return undefined; + return Number(v); +} + +/** Parse the local side, `port` or `address:port` (IPv6 in brackets). */ +function parseListen(raw: string): { bindAddress?: string; bindPort: number } | string { + const v = raw.trim(); + const bracket = /^\[([^\]]*)\]:(.*)$/.exec(v); + const [address, portRaw] = bracket + ? [bracket[1], bracket[2]] + : v.includes(':') + ? [v.slice(0, v.lastIndexOf(':')), v.slice(v.lastIndexOf(':') + 1)] + : [undefined, v]; + if (address?.includes(':') && !bracket) return 'put an IPv6 address in brackets, e.g. [::1]:8080'; + const bindPort = parsePort(portRaw); + if (bindPort == null) return `local port must be a number between 1 and 65535, got '${portRaw.trim()}'`; + return { bindAddress: address, bindPort }; +} + +/** Rows to forwards; blank rows are dropped, anything else must be complete. */ +function parseForwards(rows: ForwardRow[]): LocalForwardDto[] | string { + const forwards: LocalForwardDto[] = []; + const listening = new Map(); + for (const [i, row] of rows.entries()) { + if (!row.local.trim() && !row.remotePort.trim()) continue; + const n = i + 1; + const listen = parseListen(row.local); + if (typeof listen === 'string') return `Forward ${n}: ${listen}`; + // A bracketed IPv6 target is stored bare; the brackets are notation only. + const remoteHost = row.remoteHost.trim().replace(/^\[(.*)\]$/, '$1'); + if (!remoteHost || /[\s[\]]/.test(remoteHost)) { + return `Forward ${n}: enter the host to reach from the server, e.g. localhost`; + } + const remotePort = parsePort(row.remotePort); + if (remotePort == null) { + return `Forward ${n}: remote port must be a number between 1 and 65535, got '${row.remotePort.trim()}'`; + } + // Two rules on one local port would fail the whole tunnel at bind time. + const key = `${listen.bindAddress ?? 'localhost'}|${listen.bindPort}`; + const clash = listening.get(key); + if (clash) return `Forward ${n} listens on the same port as forward ${clash}`; + listening.set(key, n); + forwards.push({ ...listen, remoteHost, remotePort }); + } + return forwards; +} + function splitCsv(raw: string): string[] { return raw .split(',') @@ -101,6 +181,9 @@ export function formToInput(f: HostFormFields): HostFormResult { monitorPort = Number(monitorPortRaw); } + const localForwards = parseForwards(f.forwards); + if (typeof localForwards === 'string') return { ok: false, error: localForwards }; + const identityFile = f.identityFile.trim(); const password = f.password.trim(); const notes = f.notes.trim(); @@ -117,7 +200,10 @@ export function formToInput(f: HostFormFields): HostFormResult { tags, notes: notes || undefined, monitoring: f.monitoring, - monitorPort + monitorPort, + localForwards, + // The switch hides with the last row; a flag nobody can see must not linger. + tunnelAutostart: f.tunnelAutostart && localForwards.length > 0 } }; } diff --git a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts index 40489a2..033b038 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; import type { ConnectionStatusDto, HostDto, MetricsDto } from '$lib/bindings'; import type { HostServices } from '$lib/stores/services'; -import { deriveCard, metricStatus, QUICK_ACTIONS, filterHosts } from './serverCard'; +import { + deriveCard, + deriveTunnel, + filterHosts, + forwardListen, + forwardTarget, + metricStatus, + QUICK_ACTIONS +} from './serverCard'; function host(name = 'web-1'): HostDto { - return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh' }; + return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false }; } function tcpHost(name = 'fw-1'): HostDto { @@ -236,3 +244,63 @@ describe('deriveCard — reachability hosts', () => { expect(card.metricRows).toHaveLength(3); }); }); + +describe('deriveTunnel — the card\'s tunnel block', () => { + const forwarded = (): HostDto => ({ + ...host('nas'), + localForwards: [{ bindPort: 9443, remoteHost: '127.0.0.1', remotePort: 9443 }] + }); + + it('is absent for a host without forwards, whatever its status', () => { + expect(deriveTunnel(host(), { kind: 'up' })).toBeUndefined(); + expect(deriveCard(host(), CONNECTED, undefined, undefined, { kind: 'up' }).tunnel).toBeUndefined(); + }); + + it('reads as off until a status arrives, and after a stop', () => { + expect(deriveTunnel(forwarded(), undefined)).toMatchObject({ running: false, label: 'Off', dot: 'unknown' }); + }); + + it('tells running states from ended ones', () => { + expect(deriveTunnel(forwarded(), { kind: 'connecting' })).toMatchObject({ running: true, dot: 'unknown' }); + expect(deriveTunnel(forwarded(), { kind: 'up' })).toMatchObject({ running: true, dot: 'ok', label: 'Active' }); + expect(deriveTunnel(forwarded(), { kind: 'retrying', message: 'connection lost' })).toMatchObject({ + running: true, + dot: 'warn', + message: 'connection lost' + }); + expect(deriveTunnel(forwarded(), { kind: 'failed', message: 'port 9443 in use' })).toMatchObject({ + running: false, + dot: 'off', + message: 'port 9443 in use' + }); + }); + + it('rides along on a reachability card too — a port check still has SSH credentials', () => { + const card = deriveCard({ ...forwarded(), monitoring: 'tcpPort' }, CONNECTED, undefined, undefined, { kind: 'up' }); + expect(card.reachability).toBe('reachable'); + expect(card.tunnel?.label).toBe('Active'); + }); + + it('labels where a forward listens', () => { + const listen = (bindAddress: string | undefined, on = false) => + forwardListen({ bindAddress, bindPort: 80, remoteHost: 'x', remotePort: 1 }, on); + expect(listen(undefined)).toBe('localhost:80'); + expect(listen('0.0.0.0')).toBe('0.0.0.0:80'); + expect(listen('')).toBe('*:80'); + expect(listen('::1')).toBe('[::1]:80'); + // A LAN address of this machine is masked on stream; wildcards are not. + expect(listen('192.168.1.20', true)).not.toContain('192.168.1.20'); + expect(listen('0.0.0.0', true)).toBe('0.0.0.0:80'); + }); + + it('masks a target in streamer mode, but not the server\'s own loopback', () => { + const target = (remoteHost: string, on: boolean) => forwardTarget({ bindPort: 1, remoteHost, remotePort: 5432 }, on); + expect(target('10.20.30.40', false)).toBe('10.20.30.40:5432'); + expect(target('10.20.30.40', true)).not.toContain('10.20.30.40'); + for (const loopback of ['localhost', '127.0.0.1', '::1']) { + expect(target(loopback, true)).toContain(loopback); + } + expect(target('fe80::1', false)).toBe('[fe80::1]:5432'); + }); +}); + diff --git a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.ts b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.ts index 9cd1c04..ef79f73 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.ts @@ -6,10 +6,12 @@ import { derived } from 'svelte/store'; import type { ConnectionStatusDto, HostDto, + LocalForwardDto, MetricsDto, ProcessDto, ServiceDto, - ServiceKindDto + ServiceKindDto, + TunnelStatusDto } from '$lib/bindings'; import type { Status } from '$lib/theme'; import type { SessionKind } from '$lib/stores/sessions'; @@ -17,6 +19,9 @@ import { hosts } from '$lib/stores/hosts'; import { statuses } from '$lib/stores/statuses'; import { metrics } from '$lib/stores/metrics'; import { services, type HostServices } from '$lib/stores/services'; +import { tunnels } from '$lib/stores/tunnels'; +import { displayHostname } from '$lib/stores/streamer'; +import { bracketed } from './hostForm'; // Metric severity mirrors the core's `metrics::threshold_level` (Ok < 60 <= Warn <= // 85 < Crit) — the single source of truth for server-state colour (tech-gui.md §5). @@ -47,6 +52,56 @@ export interface ServerCard { topProcesses: ProcessDto[]; detectedServices: CardService[]; servicesError?: string; + /** Set only for a host with port forwards. */ + tunnel?: CardTunnel; +} + +export interface CardTunnel { + forwards: LocalForwardDto[]; + /** Connecting, up or retrying — what a Stop button stops. */ + running: boolean; + dot: Status; + label: string; + /** Why the last attempt failed, while retrying or after giving up. */ + message?: string; + autostart: boolean; +} + +/** A host's tunnel as its card shows it; `undefined` when it has no forwards. */ +export function deriveTunnel(host: HostDto, status: TunnelStatusDto | undefined): CardTunnel | undefined { + if (host.localForwards.length === 0) return undefined; + const base = { forwards: host.localForwards, autostart: host.tunnelAutostart }; + switch (status?.kind) { + case 'connecting': + return { ...base, running: true, dot: 'unknown', label: 'Connecting…' }; + case 'up': + return { ...base, running: true, dot: 'ok', label: 'Active' }; + case 'retrying': + return { ...base, running: true, dot: 'warn', label: 'Reconnecting…', message: status.message }; + case 'failed': + return { ...base, running: false, dot: 'off', label: 'Failed', message: status.message }; + default: + return { ...base, running: false, dot: 'unknown', label: 'Off' }; + } +} + +// Loopback and wildcard addresses reveal nothing, and read wrong disguised. +const REVEALS_NOTHING = /^(localhost|127(\.\d{1,3}){3}|::1|\*|0\.0\.0\.0|::)$/i; + +/** An address as a forward shows it: masked in streamer mode like any host address. */ +function shown(address: string, streamerOn: boolean): string { + return REVEALS_NOTHING.test(address) ? address : displayHostname(address, streamerOn); +} + +/** Where a forward listens: `localhost` unless the rule names an address, `*` for all. */ +export function forwardListen(f: LocalForwardDto, streamerOn: boolean): string { + const bind = f.bindAddress == null ? 'localhost' : f.bindAddress === '' ? '*' : f.bindAddress; + return `${bracketed(shown(bind, streamerOn))}:${f.bindPort}`; +} + +/** Where a forward leads, resolved on the server. */ +export function forwardTarget(f: LocalForwardDto, streamerOn: boolean): string { + return `${bracketed(shown(f.remoteHost, streamerOn))}:${f.remotePort}`; } const SEVERITY: Status[] = ['ok', 'warn', 'crit']; @@ -95,7 +150,8 @@ export function deriveCard( host: HostDto, status: ConnectionStatusDto | undefined, m: MetricsDto | undefined, - svc: HostServices | undefined + svc: HostServices | undefined, + tunnel?: TunnelStatusDto ): ServerCard { // A reachability host is probed by a TCP connect and never reports metrics, so // the tiles would be a fiction — the card shows the probe result instead. @@ -108,7 +164,8 @@ export function deriveCard( reachability: kind === 'connected' ? 'reachable' : kind === 'failed' ? 'unreachable' : 'checking', metricRows: [], topProcesses: [], - detectedServices: [] + detectedServices: [], + tunnel: deriveTunnel(host, tunnel) }; } @@ -139,16 +196,23 @@ export function deriveCard( osInfo: m?.osInfo ?? undefined, topProcesses: m?.topProcesses ?? [], detectedServices, - servicesError: svc?.kind === 'failed' ? svc.message : undefined + servicesError: svc?.kind === 'failed' ? svc.message : undefined, + tunnel: deriveTunnel(host, tunnel) }; } /** Live dashboard cards, one per host, recomputed as any live store changes. */ export const serverCards = derived( - [hosts, statuses, metrics, services], - ([$hosts, $statuses, $metrics, $services]) => + [hosts, statuses, metrics, services, tunnels], + ([$hosts, $statuses, $metrics, $services, $tunnels]) => $hosts.map((host) => - deriveCard(host, $statuses.get(host.name), $metrics.get(host.name), $services.get(host.name)) + deriveCard( + host, + $statuses.get(host.name), + $metrics.get(host.name), + $services.get(host.name), + $tunnels.get(host.name) + ) ) ); diff --git a/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts index cc9c4f9..a626a5d 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts @@ -3,7 +3,7 @@ import type { ConnectionStatusDto, HostDto, MetricsDto } from '$lib/bindings'; import { deriveHostSummary } from './hostSummary'; function host(name: string): HostDto { - return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh' }; + return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false }; } function metrics(partial: Partial): MetricsDto { diff --git a/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts index 6fda2d5..d98ce60 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts @@ -14,6 +14,8 @@ function host(name: string, extra: Partial = {}): HostDto { source: 'manual', hasKey: false, monitoring: 'ssh', + localForwards: [], + tunnelAutostart: false, ...extra }; } diff --git a/crates/omnyssh-gui/ui/src/lib/stores/tunnels.ts b/crates/omnyssh-gui/ui/src/lib/stores/tunnels.ts new file mode 100644 index 0000000..0f596a1 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/tunnels.ts @@ -0,0 +1,6 @@ +import { writable } from 'svelte/store'; +import type { TunnelStatusDto } from '$lib/bindings'; + +/** Latest tunnel status per host, keyed by host name; a host with no entry has no + * tunnel running (tech-gui.md §3.5). */ +export const tunnels = writable>(new Map()); diff --git a/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte b/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte index 255facc..b0b45f8 100644 --- a/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte +++ b/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte @@ -103,5 +103,10 @@ + {:else if name === 'tunnel'} + + + + {/if} diff --git a/crates/omnyssh-gui/ui/src/lib/theme/icons.ts b/crates/omnyssh-gui/ui/src/lib/theme/icons.ts index c8701f0..c4916ea 100644 --- a/crates/omnyssh-gui/ui/src/lib/theme/icons.ts +++ b/crates/omnyssh-gui/ui/src/lib/theme/icons.ts @@ -26,4 +26,5 @@ export type IconName = | 'shield' | 'settings' | 'telegram' - | 'star'; + | 'star' + | 'tunnel'; diff --git a/crates/omnyssh/src/app/action.rs b/crates/omnyssh/src/app/action.rs index bb5521d..c5b9f9d 100644 --- a/crates/omnyssh/src/app/action.rs +++ b/crates/omnyssh/src/app/action.rs @@ -41,6 +41,8 @@ pub enum AppAction { ConfirmKeySetup(usize), /// User cancelled the key setup prompt. CancelKeySetup, + /// Start or stop the selected host's port-forwarding tunnel. + ToggleTunnel, // ----------------------------------------------------------------------- // Detail View actions diff --git a/crates/omnyssh/src/app/actions.rs b/crates/omnyssh/src/app/actions.rs index f4f789f..9307ae0 100644 --- a/crates/omnyssh/src/app/actions.rs +++ b/crates/omnyssh/src/app/actions.rs @@ -256,6 +256,33 @@ impl App { self.view.host_list.popup = None; } + AppAction::ToggleTunnel => { + let host = { + let state = self.state.read().await; + self.view + .host_list + .selected_host_idx() + .and_then(|i| state.hosts.get(i)) + .cloned() + }; + let (Some(host), Some(tunnels)) = (host, &mut self.tunnel_manager) else { + return Ok(()); + }; + if host.local_forwards.is_empty() { + self.view.status_message = Some(format!( + "No port forwards set for '{}' — add them with e", + host.name + )); + } else if tunnels.is_running(&host.name) { + tunnels.stop(&host.name); + self.view.status_message = Some(format!("Tunnel for '{}' stopped", host.name)); + } else { + self.view.status_message = + Some(format!("Starting tunnel for '{}'…", host.name)); + tunnels.start(host); + } + } + // --------------------------------------------------------------- // Detail View actions // --------------------------------------------------------------- diff --git a/crates/omnyssh/src/app/host.rs b/crates/omnyssh/src/app/host.rs index 4cb9459..ff4e21b 100644 --- a/crates/omnyssh/src/app/host.rs +++ b/crates/omnyssh/src/app/host.rs @@ -5,6 +5,7 @@ use std::time::Duration; use super::*; use omnyssh_core::ssh::client::{HostSource, MonitorMode}; +use omnyssh_core::ssh::tunnel::LocalForward; // --------------------------------------------------------------------------- // Host form (used in Add / Edit popups) @@ -21,6 +22,8 @@ pub const FORM_FIELD_LABELS: &[&str] = &[ "Tags (comma-sep)", "Notes", "Monitoring (ssh | tcp | tcp:PORT)", + "Port forwards (port:host:hostport, ...)", + "Start tunnel on launch (y/n)", ]; /// Whether an edit changed anything a running poller reads. Everything else on @@ -67,6 +70,58 @@ fn parse_monitoring(value: &str) -> Result<(MonitorMode, Option), String> { } } +/// Renders a host's port forwards the way their form field holds them. +pub fn forwards_value(host: &Host) -> String { + host.local_forwards + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +/// Parses the port forwards field: comma-separated `ssh -L` rules. +fn parse_forwards(value: &str) -> Result, String> { + let forwards: Vec = value + .split(',') + .map(str::trim) + .filter(|rule| !rule.is_empty()) + .map(|rule| rule.parse().map_err(|e| format!("Port forward: {e}"))) + .collect::>()?; + // Two rules on one local port would fail the whole tunnel when it binds. + let mut listening = std::collections::HashSet::new(); + for forward in &forwards { + let address = forward.bind_address.as_deref().unwrap_or("localhost"); + if !listening.insert((address, forward.bind_port)) { + return Err(format!( + "Port forward: two rules listen on port {}", + forward.bind_port + )); + } + } + Ok(forwards) +} + +/// Renders the autostart flag into its form field; off stays blank like the +/// other optional fields. +fn autostart_value(host: &Host) -> &'static str { + if host.tunnel_autostart { + "y" + } else { + "" + } +} + +fn parse_autostart(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "n" | "no" => Ok(false), + "y" | "yes" => Ok(true), + _ => Err(format!( + "Start tunnel on launch must be y or n, got '{}'", + value.trim() + )), + } +} + /// A single editable text field in the host form. #[derive(Debug, Clone, Default)] pub struct FormField { @@ -137,6 +192,8 @@ impl HostForm { form.fields[6] = FormField::with_value(host.tags.join(", ")); form.fields[7] = FormField::with_value(host.notes.as_deref().unwrap_or("")); form.fields[8] = FormField::with_value(monitoring_value(host)); + form.fields[9] = FormField::with_value(forwards_value(host)); + form.fields[10] = FormField::with_value(autostart_value(host)); form } @@ -210,6 +267,8 @@ impl HostForm { }; let (monitoring, monitor_port) = parse_monitoring(self.fields[8].value.trim())?; + let local_forwards = parse_forwards(&self.fields[9].value)?; + let tunnel_autostart = parse_autostart(&self.fields[10].value)?; Ok(Host { name, @@ -225,6 +284,8 @@ impl HostForm { original_ssh_host: None, monitoring, monitor_port, + local_forwards, + tunnel_autostart, key_setup_date: None, password_auth_disabled: None, }) @@ -546,6 +607,7 @@ impl App { } self.save_manual_hosts().await; + self.sync_tunnels().await; // The edit can change the address, the port or the monitoring // mode, none of which a running poller picks up. The pool has no @@ -599,6 +661,7 @@ impl App { } } self.save_manual_hosts().await; + self.sync_tunnels().await; let state = self.state.read().await; self.view.host_list.rebuild_filter( &state.hosts, @@ -616,6 +679,22 @@ impl App { self.view.status_message = Some(format!("Save failed: {e}")); } } + + /// Brings the running tunnels in line with the host list. A failed tunnel + /// reports nothing more, so its status is dropped here once its host is + /// gone — a later host of that name must not inherit it. + pub(crate) async fn sync_tunnels(&mut self) { + let mut state = self.state.write().await; + if let Some(tunnels) = &mut self.tunnel_manager { + tunnels.sync(&state.hosts); + } + let AppState { + hosts, + tunnel_statuses, + .. + } = &mut *state; + tunnel_statuses.retain(|name, _| hosts.iter().any(|h| &h.name == name)); + } } #[cfg(test)] @@ -735,6 +814,115 @@ mod tests { } } + fn forwarded_host() -> Host { + Host { + name: String::from("web"), + hostname: String::from("10.0.0.1"), + local_forwards: [ + "9443:127.0.0.1:9443", + "0.0.0.0:5432:db.internal:5432", + "[::1]:8080:[fe80::1]:80", + ] + .iter() + .map(|rule| rule.parse().expect("valid rule")) + .collect(), + tunnel_autostart: true, + ..Host::default() + } + } + + #[test] + fn the_forwards_field_round_trips_through_the_form() { + let host = forwarded_host(); + assert_eq!( + forwards_value(&host), + "9443:127.0.0.1:9443, 0.0.0.0:5432:db.internal:5432, [::1]:8080:[fe80::1]:80" + ); + assert_eq!( + parse_forwards(&forwards_value(&host)), + Ok(host.local_forwards) + ); + assert_eq!( + parse_forwards(" 9443:localhost:9443 , ,"), + Ok(vec!["9443:localhost:9443".parse().unwrap()]) + ); + assert_eq!(parse_forwards(""), Ok(Vec::new())); + } + + #[test] + fn two_rules_on_one_local_port_are_rejected() { + for text in ["8080:a:80, 8080:b:80", "8080:a:80, localhost:8080:b:80"] { + assert_eq!( + parse_forwards(text), + Err(String::from("Port forward: two rules listen on port 8080")) + ); + } + // The same port on two separate addresses is two sockets. + assert!(parse_forwards("10.0.0.5:8080:a:80, 10.0.0.6:8080:b:80").is_ok()); + } + + #[test] + fn a_bad_forward_is_rejected_as_one() { + for text in ["9443", "9443:localhost:0", "9443:localhost:9443, nonsense"] { + let err = parse_forwards(text).expect_err(text); + assert!(err.starts_with("Port forward: "), "'{text}' gave '{err}'"); + } + } + + #[test] + fn the_autostart_field_round_trips_through_the_form() { + for (text, on) in [ + ("", false), + ("n", false), + (" No ", false), + ("y", true), + ("YES", true), + ] { + assert_eq!(parse_autostart(text), Ok(on), "parsing '{text}'"); + } + for on in [false, true] { + let host = Host { + tunnel_autostart: on, + ..Host::default() + }; + assert_eq!(parse_autostart(autostart_value(&host)), Ok(on)); + } + for text in ["maybe", "1", "true"] { + assert_eq!( + parse_autostart(text), + Err(format!( + "Start tunnel on launch must be y or n, got '{text}'" + )) + ); + } + } + + #[test] + fn an_edit_keeps_the_forwards_and_autostart() { + // Forwards set in the GUI or read from ~/.ssh/config have to survive an + // edit that never touches them. + let host = forwarded_host(); + let edited = HostForm::from_host(&host) + .to_host(HostSource::Manual) + .expect("the form round-trips a valid host"); + assert_eq!(edited.local_forwards, host.local_forwards); + assert!(edited.tunnel_autostart); + + let mut form = HostForm::from_host(&host); + form.fields[9] = FormField::with_value("9443:localhost:9443, 99999:localhost:1"); + assert!(form.to_host(HostSource::Manual).is_err()); + } + + #[test] + fn a_tunnel_edit_does_not_restart_the_pool() { + let base = Host { + name: String::from("web"), + hostname: String::from("10.0.0.1"), + ..Host::default() + }; + assert!(!poller_inputs_changed(&base, &forwarded_host())); + } + #[test] fn an_unusable_monitoring_value_is_rejected() { for text in ["tcp:0", "tcp:99999", "http", "tcp:"] { diff --git a/crates/omnyssh/src/app/mod.rs b/crates/omnyssh/src/app/mod.rs index c1c81e9..7bdf006 100644 --- a/crates/omnyssh/src/app/mod.rs +++ b/crates/omnyssh/src/app/mod.rs @@ -27,6 +27,7 @@ use omnyssh_core::ssh::client::{ConnectionStatus, Host}; use omnyssh_core::ssh::pool::PollManager; use omnyssh_core::ssh::pty::PtyManager; use omnyssh_core::ssh::sftp::{SftpCommand, SftpManager}; +use omnyssh_core::ssh::tunnel::{TunnelManager, TunnelStatus}; mod action; mod actions; @@ -121,6 +122,9 @@ pub struct AppState { pub snippets: Vec, /// Detected services per host. pub services: HashMap>, + /// Status of each host's tunnel, keyed by `host.name`. No entry means none + /// is running. + pub tunnel_statuses: HashMap, } // --------------------------------------------------------------------------- @@ -209,6 +213,11 @@ pub struct App { poll_manager: Option, /// PTY session manager for the Terminal multi-session screen. pty_manager: Option, + /// Runs the hosts' port-forwarding tunnels. Taken on quit to stop them. + tunnel_manager: Option, + /// Set by the first `HostsLoaded`: autostart belongs to launch, not to a + /// reload. + hosts_loaded: bool, /// One heavyweight event (Key, etc.) that was pulled from the channel /// during a lightweight-event drain but could not be handled inline. /// Consumed at the top of the next main-loop iteration before blocking @@ -228,6 +237,7 @@ impl App { let (core_tx, core_rx) = mpsc::channel(256); let theme = Theme::from_name(&config.ui.theme); let keybindings = ParsedKeybindings::from_config(&config.keybindings); + let tunnel_manager = TunnelManager::new(core_tx.clone()); Self { state: Arc::new(RwLock::new(AppState::default())), view: ViewState { @@ -243,6 +253,8 @@ impl App { next_transfer_id: 0, poll_manager: None, pty_manager: None, + tunnel_manager: Some(tunnel_manager), + hosts_loaded: false, pending_event: None, config, } @@ -342,6 +354,10 @@ impl App { if let Some(mgr) = self.pty_manager.take() { mgr.shutdown(); } + // Stop every tunnel, releasing its local ports. + if let Some(mgr) = self.tunnel_manager.take() { + mgr.shutdown(); + } // Terminal restore — always runs even if main_loop returned Err. // Each step runs unconditionally so a failure in one does not prevent @@ -585,6 +601,12 @@ impl App { Duration::from_secs(30), )); } + // A reload only brings running tunnels in line with the new list. + if std::mem::replace(&mut self.hosts_loaded, true) { + self.sync_tunnels().await; + } else if let Some(tunnels) = &mut self.tunnel_manager { + tunnels.autostart(&self.state.read().await.hosts); + } tracing::info!("Loaded {} host(s)", n); } @@ -677,6 +699,25 @@ impl App { )); } + CoreEvent::TunnelStatusChanged(host_name, status) => { + match &status { + TunnelStatus::Up => { + self.view.status_message = Some(format!("Tunnel for '{host_name}' is up")); + } + TunnelStatus::Failed(reason) => { + self.view.status_message = + Some(format!("Tunnel for '{host_name}' failed: {reason}")); + } + _ => {} + } + let mut state = self.state.write().await; + if status == TunnelStatus::Stopped { + state.tunnel_statuses.remove(&host_name); + } else { + state.tunnel_statuses.insert(host_name, status); + } + } + // ---------------------------------------------------------------- // Auto SSH Key Setup events // ---------------------------------------------------------------- @@ -718,6 +759,9 @@ impl App { tracing::warn!("Failed to save hosts after key setup: {}", e); } } + // The server now refuses the password a running tunnel would + // redial with. + self.sync_tunnels().await; // Close popup and show success. self.view.host_list.popup = None; diff --git a/crates/omnyssh/src/ui/card.rs b/crates/omnyssh/src/ui/card.rs index 8ab2584..49036c3 100644 --- a/crates/omnyssh/src/ui/card.rs +++ b/crates/omnyssh/src/ui/card.rs @@ -15,6 +15,7 @@ use crate::ui::theme::threshold_color; use crate::ui::theme::Theme; use omnyssh_core::event::{DetectedService, Metrics, ServiceKind}; use omnyssh_core::ssh::client::{ConnectionStatus, MonitorMode}; +use omnyssh_core::ssh::tunnel::TunnelStatus; // --------------------------------------------------------------------------- // Card dimensions (kept in sync with dashboard.rs column calculation) @@ -39,6 +40,16 @@ fn status_dot(status: Option<&ConnectionStatus>) -> (&'static str, Color) { } } +/// Colour of the tunnel glyph; `None` means no tunnel is running. +fn tunnel_color(status: Option<&TunnelStatus>, theme: &Theme) -> Color { + match status { + Some(TunnelStatus::Up) => theme.text_success, + Some(TunnelStatus::Connecting | TunnelStatus::Retrying(_)) => theme.text_warning, + Some(TunnelStatus::Failed(_)) => theme.text_error, + Some(TunnelStatus::Stopped) | None => theme.text_muted, + } +} + // --------------------------------------------------------------------------- // Public render function // --------------------------------------------------------------------------- @@ -58,6 +69,10 @@ pub struct CardData<'a> { pub monitoring: MonitorMode, /// Port the reachability probe dials, when it is not the host's own. pub monitor_port: Option, + /// Whether the host defines port forwards, and so has a tunnel to show. + pub has_forwards: bool, + /// The tunnel's status; `None` when it is not running. + pub tunnel: Option<&'a TunnelStatus>, } /// The reachability line shown in place of the metric rows. Naming the probed @@ -95,11 +110,19 @@ pub fn render_card( let status = data.status; // ---- Border ---- let (dot, dot_color) = status_dot(status); + // The tunnel glyph takes two more columns from the name. + let reserved = if data.has_forwards { 8 } else { 6 }; let title = format!( " {} ", - truncate(host_name, rect.width.saturating_sub(6) as usize) + truncate(host_name, rect.width.saturating_sub(reserved) as usize) ); - let title_right = format!(" {} ", dot); + let dot_span = Span::styled(format!(" {} ", dot), Style::default().fg(dot_color)); + let title_right = if data.has_forwards { + let glyph = Style::default().fg(tunnel_color(data.tunnel, theme)); + Line::from(vec![Span::styled(" ⇄", glyph), dot_span]) + } else { + Line::from(dot_span) + }; let border_color = if is_selected { theme.accent @@ -125,10 +148,7 @@ pub fn render_card( .fg(title_color) .add_modifier(Modifier::BOLD), ) - .title_top( - Line::from(Span::styled(title_right, Style::default().fg(dot_color))) - .alignment(Alignment::Right), - ) + .title_top(title_right.alignment(Alignment::Right)) .borders(Borders::ALL) .border_type(border_type) .border_style(Style::default().fg(border_color)); diff --git a/crates/omnyssh/src/ui/dashboard.rs b/crates/omnyssh/src/ui/dashboard.rs index c2c61a4..34e4ee4 100644 --- a/crates/omnyssh/src/ui/dashboard.rs +++ b/crates/omnyssh/src/ui/dashboard.rs @@ -145,7 +145,7 @@ fn render_header(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewSta } // Build key hints. - let mut hints = String::from("r:refresh s:sort t:tags /:search a:add x:execute"); + let mut hints = String::from("r:refresh s:sort t:tags /:search a:add x:execute f:tunnel"); // Check if selected host needs SSH key setup. // Show "Shift+K:ssh-setup" hint if selected host has password but no identity_file. @@ -268,6 +268,8 @@ fn render_grid(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState services: state.services.get(&host.name).map(|s| s.as_slice()), monitoring: host.monitoring, monitor_port: host.monitor_port, + has_forwards: !host.local_forwards.is_empty(), + tunnel: state.tunnel_statuses.get(&host.name), }, is_selected, &view.theme, @@ -368,6 +370,9 @@ pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option { // SSH key setup for selected host. KeyCode::Char('K') => Some(AppAction::StartKeySetup), + // Start / stop the selected host's tunnel. + KeyCode::Char('f') => Some(AppAction::ToggleTunnel), + // Esc: clear status message / search query. KeyCode::Esc => { if !view.host_list.search_query.is_empty() { diff --git a/crates/omnyssh/src/ui/detail_view.rs b/crates/omnyssh/src/ui/detail_view.rs index 1ff0151..68311bc 100644 --- a/crates/omnyssh/src/ui/detail_view.rs +++ b/crates/omnyssh/src/ui/detail_view.rs @@ -12,11 +12,12 @@ use ratatui::{ Frame, }; -use crate::app::{AppAction, AppState, SnippetPopup, ViewState}; +use crate::app::{forwards_value, AppAction, AppState, SnippetPopup, ViewState}; use crate::ui::theme::threshold_color; use crate::ui::theme::Theme; use omnyssh_core::event::{DetectedService, Metrics, ServiceKind}; use omnyssh_core::ssh::client::{ConnectionStatus, MonitorMode}; +use omnyssh_core::ssh::tunnel::TunnelStatus; // --------------------------------------------------------------------------- // Render @@ -73,6 +74,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) let metrics = state.metrics.get(&host.name); let status = state.connection_statuses.get(&host.name); let services = state.services.get(&host.name); + let tunnel = state.tunnel_statuses.get(&host.name); // Main border with title let title = format!(" {} ", host.name); @@ -106,7 +108,15 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) render_hints(frame, sections[0], &view.theme); // Header section - render_header(frame, sections[1], host, metrics, status, &view.theme); + render_header( + frame, + sections[1], + host, + metrics, + status, + tunnel, + &view.theme, + ); // Separator render_separator(frame, sections[2], inner.width, &view.theme); @@ -151,6 +161,7 @@ fn render_header( host: &omnyssh_core::ssh::client::Host, metrics: Option<&Metrics>, status: Option<&ConnectionStatus>, + tunnel: Option<&TunnelStatus>, theme: &Theme, ) { let status_text = match status { @@ -190,7 +201,7 @@ fn render_header( .and_then(|m| m.os_info.as_deref()) .unwrap_or("(discovery pending)"); - let line2 = Line::from(vec![ + let mut line2 = Line::from(vec![ Span::styled(" OS: ", Style::default().fg(theme.text_secondary)), Span::styled( os_display, @@ -202,6 +213,27 @@ fn render_header( ), ]); + // The forwards come before the state so a long failure reason is what + // gets clipped. + if !host.local_forwards.is_empty() { + let forwards = forwards_value(host); + let (state, color) = match tunnel { + Some(TunnelStatus::Up) => (String::from("up"), theme.text_success), + Some(TunnelStatus::Connecting) => (String::from("connecting"), theme.text_warning), + Some(TunnelStatus::Retrying(reason)) => { + (format!("retrying: {reason}"), theme.text_warning) + } + Some(TunnelStatus::Failed(reason)) => (format!("failed: {reason}"), theme.text_error), + Some(TunnelStatus::Stopped) | None => (String::from("off"), theme.text_muted), + }; + line2.spans.extend([ + Span::styled(" Tunnel: ", Style::default().fg(theme.text_secondary)), + Span::styled(forwards, Style::default().fg(theme.accent)), + Span::raw(" — "), + Span::styled(state, Style::default().fg(color)), + ]); + } + let header_text = vec![line1, line2]; frame.render_widget(Paragraph::new(header_text), area); } @@ -578,6 +610,14 @@ fn render_hints(frame: &mut Frame, area: Rect, theme: &Theme) { .add_modifier(Modifier::BOLD), ), Span::styled(":Quick view", Style::default().fg(theme.text_muted)), + Span::raw(" "), + Span::styled( + "f", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(":Tunnel", Style::default().fg(theme.text_muted)), ]); frame.render_widget(Paragraph::new(hints), area); @@ -625,6 +665,9 @@ pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option { KeyCode::Char('8') => Some(AppAction::ShowQuickView(ServiceKind::NodeJS)), KeyCode::Char('9') => None, // Reserved for future use + // Start / stop this host's tunnel. + KeyCode::Char('f') => Some(AppAction::ToggleTunnel), + _ => None, } } diff --git a/crates/omnyssh/src/ui/popup.rs b/crates/omnyssh/src/ui/popup.rs index 3c73014..0628d3d 100644 --- a/crates/omnyssh/src/ui/popup.rs +++ b/crates/omnyssh/src/ui/popup.rs @@ -1,3 +1,5 @@ +use std::ops::Range; + use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -150,6 +152,10 @@ pub fn render_help(frame: &mut Frame, theme: &Theme) { Span::styled(" K", key_style), Span::styled(" Setup SSH key", desc_style), ])); + col1_lines.push(Line::from(vec![ + Span::styled(" f", key_style), + Span::styled(" Tunnel on/off", desc_style), + ])); col1_lines.push(Line::from(vec![ Span::styled(" hjkl", key_style), Span::styled(" Navigate", desc_style), @@ -176,6 +182,10 @@ pub fn render_help(frame: &mut Frame, theme: &Theme) { Span::styled(" 4-9", key_style), Span::styled(" Quick view", desc_style), ])); + col2_lines.push(Line::from(vec![ + Span::styled(" f", key_style), + Span::styled(" Tunnel on/off", desc_style), + ])); col2_lines.push(Line::from("")); col2_lines.push(Line::from(Span::styled(" FILE MANAGER", section_style))); @@ -338,7 +348,11 @@ pub fn render_host_form(frame: &mut Frame, form: &HostForm, title: &str, theme: let inner = block.inner(area); frame.render_widget(block, area); - let num_fields = FORM_FIELD_LABELS.len(); + // A terminal too short for every field shows the ones around the focus; + // the padding and spacer rows point at the ones scrolled out. + let capacity = usize::from(inner.height.saturating_sub(3) / 2); + let window = field_window(FORM_FIELD_LABELS.len(), form.focused_field, capacity); + let num_fields = window.len(); // 1 blank line top + 2 lines per field (label + input) + 2 hint lines let mut constraints: Vec = Vec::with_capacity(num_fields * 2 + 3); constraints.push(Constraint::Length(1)); // top padding @@ -367,9 +381,23 @@ pub fn render_host_form(frame: &mut Frame, form: &HostForm, title: &str, theme: .fg(theme.accent) .add_modifier(Modifier::BOLD); - for (i, label) in FORM_FIELD_LABELS.iter().enumerate() { - let label_row = rows[1 + i * 2]; - let input_row = rows[2 + i * 2]; + let more_style = Style::default().fg(theme.text_muted); + if window.start > 0 { + let more = format!(" ↑ {} more", window.start); + frame.render_widget(Paragraph::new(Span::styled(more, more_style)), rows[0]); + } + if window.end < FORM_FIELD_LABELS.len() { + let more = format!(" ↓ {} more", FORM_FIELD_LABELS.len() - window.end); + frame.render_widget( + Paragraph::new(Span::styled(more, more_style)), + rows[1 + num_fields * 2], + ); + } + + for (row, i) in window.enumerate() { + let label = FORM_FIELD_LABELS[i]; + let label_row = rows[1 + row * 2]; + let input_row = rows[2 + row * 2]; let is_focused = i == form.focused_field; // Label @@ -435,6 +463,16 @@ pub fn render_host_form(frame: &mut Frame, form: &HostForm, title: &str, theme: } } +/// The run of `count` form fields that fits in `capacity` slots and holds the +/// `focused` one. Scrolls only once the focus passes the last slot. +fn field_window(count: usize, focused: usize, capacity: usize) -> Range { + let capacity = capacity.clamp(1, count.max(1)); + let start = focused + .saturating_sub(capacity - 1) + .min(count.saturating_sub(capacity)); + start..start + capacity.min(count) +} + // --------------------------------------------------------------------------- // Delete confirmation popup // --------------------------------------------------------------------------- @@ -1686,3 +1724,35 @@ pub fn render_update(frame: &mut Frame, popup: &UpdatePopup, theme: &Theme) { }; frame.render_widget(Paragraph::new(hint), rows[2]); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_form_window_always_holds_the_focused_field() { + for count in 1..=12 { + for capacity in 0..=13 { + for focused in 0..count { + let window = field_window(count, focused, capacity); + assert!( + window.contains(&focused), + "{count} fields, {capacity} slots, focus {focused}: {window:?}" + ); + assert_eq!(window.len(), capacity.clamp(1, count)); + assert!(window.end <= count); + } + } + } + } + + #[test] + fn the_form_window_scrolls_only_past_the_last_slot() { + // 11 fields at the 80x24 minimum leave room for 9. + assert_eq!(field_window(11, 0, 9), 0..9); + assert_eq!(field_window(11, 8, 9), 0..9); + assert_eq!(field_window(11, 9, 9), 1..10); + assert_eq!(field_window(11, 10, 9), 2..11); + assert_eq!(field_window(11, 10, 20), 0..11); + } +}