diff --git a/Cargo.lock b/Cargo.lock index 98ac9d0..4434b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,6 +1089,7 @@ dependencies = [ "prt-core", "ratatui", "serde_json", + "shlex", ] [[package]] diff --git a/crates/prt-core/src/core/suspicious.rs b/crates/prt-core/src/core/suspicious.rs index 60a3139..cef85c8 100644 --- a/crates/prt-core/src/core/suspicious.rs +++ b/crates/prt-core/src/core/suspicious.rs @@ -9,6 +9,7 @@ //! 1. **NonRootPrivileged** — non-root process listening on port < 1024 //! 2. **ScriptOnSensitive** — scripting language on a sensitive port (22, 80, 443) //! 3. **RootHighPortOutgoing** — root process with outgoing connection to high port +//! 4. **ProxyListening** — process listening on a well-known SOCKS/proxy port use crate::model::{ConnectionState, PortEntry, SuspiciousReason}; @@ -18,6 +19,22 @@ const SCRIPT_NAMES: &[&str] = &["python", "python3", "perl", "ruby", "node"]; /// Sensitive ports that should not normally be served by scripting languages. const SENSITIVE_PORTS: &[u16] = &[22, 80, 443]; +/// Well-known SOCKS/proxy listener ports. Deliberately narrow — `8080`/`8443` +/// are excluded because they are far more often legitimate HTTP(S)-alt servers +/// than proxies, which would make this heuristic too noisy. +/// +/// NOTE: intentionally a separate, narrower list from the service-name table in +/// `known_ports.rs` (which labels ports for display). Adding a proxy port to +/// `known_ports` does not flag it here, and vice versa — keep the two in sync +/// by hand when it matters. +const PROXY_PORTS: &[u16] = &[ + 1080, // SOCKS + 1081, // SOCKS (alt) + 3128, // Squid + 9050, // Tor SOCKS + 9150, // Tor Browser SOCKS +]; + /// Run all heuristics on a single port entry and return matching reasons. pub fn check(entry: &PortEntry) -> Vec { let mut reasons = Vec::new(); @@ -31,6 +48,9 @@ pub fn check(entry: &PortEntry) -> Vec { if is_root_high_port_outgoing(entry) { reasons.push(SuspiciousReason::RootHighPortOutgoing); } + if is_proxy_listening(entry) { + reasons.push(SuspiciousReason::ProxyListening); + } reasons } @@ -83,6 +103,15 @@ fn is_root_high_port_outgoing(entry: &PortEntry) -> bool { } } +/// Process listening on a well-known SOCKS/proxy port (see [`PROXY_PORTS`]). +/// +/// A SOCKS/proxy listener is worth surfacing: it may be a legitimate `ssh -D` +/// tunnel, but it can equally be an attacker pivoting traffic through the host. +/// Flagging it lets the operator confirm the listener is one they started. +fn is_proxy_listening(entry: &PortEntry) -> bool { + entry.state == ConnectionState::Listen && PROXY_PORTS.contains(&entry.local_addr.port()) +} + #[cfg(test)] mod tests { use super::*; @@ -293,4 +322,34 @@ mod tests { let entry = listen_entry(443, "nginx", Some("root")); assert!(check(&entry).is_empty()); } + + // ── ProxyListening ─────────────────────────────────────────── + + #[test] + fn socks_listener_flagged() { + let entry = listen_entry(1080, "ssh", Some("user")); + assert!(is_proxy_listening(&entry)); + assert!(check(&entry).contains(&SuspiciousReason::ProxyListening)); + } + + #[test] + fn tor_socks_listener_flagged() { + let entry = listen_entry(9050, "tor", Some("debian-tor")); + assert!(check(&entry).contains(&SuspiciousReason::ProxyListening)); + } + + #[test] + fn http_alt_8080_not_flagged_as_proxy() { + // 8080 is excluded to avoid false positives on legitimate http-alt. + let entry = listen_entry(8080, "nginx", Some("www-data")); + assert!(!is_proxy_listening(&entry)); + } + + #[test] + fn proxy_port_established_not_flagged() { + // An outgoing connection to a proxy port is not a local proxy listener. + let mut entry = listen_entry(1080, "curl", Some("user")); + entry.state = ConnectionState::Established; + assert!(!is_proxy_listening(&entry)); + } } diff --git a/crates/prt-core/src/i18n/en.rs b/crates/prt-core/src/i18n/en.rs index 7d6b44e..c393a48 100644 --- a/crates/prt-core/src/i18n/en.rs +++ b/crates/prt-core/src/i18n/en.rs @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "Remote", tunnel_col_host: "Host", tunnel_col_status: "Status", + tunnel_col_uptime: "Uptime", tunnel_status_alive: "alive", tunnel_status_dead: "dead", tunnel_status_starting: "starting", tunnel_status_failed: "failed", + tunnel_health_no_listener: "no listener", tunnel_form_edit_title: " Edit SSH Tunnel ", tunnel_form_field_required: "required", tunnels_empty: " No active tunnels. Press [n] to create one.", @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "new", hint_kill_tunnel: "kill", hint_restart_tunnel: "restart", + hint_copy_tunnel: "copy cmd", hint_save_tunnels: "save", hint_reload: "reload", hint_open_tunnel: "tunnel", diff --git a/crates/prt-core/src/i18n/mod.rs b/crates/prt-core/src/i18n/mod.rs index 1892c76..0654701 100644 --- a/crates/prt-core/src/i18n/mod.rs +++ b/crates/prt-core/src/i18n/mod.rs @@ -249,10 +249,12 @@ pub struct Strings { pub tunnel_col_remote: &'static str, pub tunnel_col_host: &'static str, pub tunnel_col_status: &'static str, + pub tunnel_col_uptime: &'static str, pub tunnel_status_alive: &'static str, pub tunnel_status_dead: &'static str, pub tunnel_status_starting: &'static str, pub tunnel_status_failed: &'static str, + pub tunnel_health_no_listener: &'static str, pub tunnel_form_edit_title: &'static str, pub tunnel_form_field_required: &'static str, pub tunnels_empty: &'static str, @@ -277,6 +279,7 @@ pub struct Strings { pub hint_new_tunnel: &'static str, pub hint_kill_tunnel: &'static str, pub hint_restart_tunnel: &'static str, + pub hint_copy_tunnel: &'static str, pub hint_save_tunnels: &'static str, pub hint_reload: &'static str, pub hint_open_tunnel: &'static str, diff --git a/crates/prt-core/src/i18n/ru.rs b/crates/prt-core/src/i18n/ru.rs index ee42127..efd2c38 100644 --- a/crates/prt-core/src/i18n/ru.rs +++ b/crates/prt-core/src/i18n/ru.rs @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "Удалённ.", tunnel_col_host: "Хост", tunnel_col_status: "Статус", + tunnel_col_uptime: "Аптайм", tunnel_status_alive: "активен", tunnel_status_dead: "мёртв", tunnel_status_starting: "запускается", tunnel_status_failed: "сбой", + tunnel_health_no_listener: "нет листенера", tunnel_form_edit_title: " Правка SSH-туннеля ", tunnel_form_field_required: "обязательно", tunnels_empty: " Активных туннелей нет. Нажмите [n] чтобы создать.", @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "новый", hint_kill_tunnel: "убить", hint_restart_tunnel: "рестарт", + hint_copy_tunnel: "копир. cmd", hint_save_tunnels: "сохр.", hint_reload: "обновить", hint_open_tunnel: "туннель", diff --git a/crates/prt-core/src/i18n/zh.rs b/crates/prt-core/src/i18n/zh.rs index fcd0599..1f1d3e3 100644 --- a/crates/prt-core/src/i18n/zh.rs +++ b/crates/prt-core/src/i18n/zh.rs @@ -142,10 +142,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "远端", tunnel_col_host: "主机", tunnel_col_status: "状态", + tunnel_col_uptime: "运行时长", tunnel_status_alive: "活跃", tunnel_status_dead: "已断", tunnel_status_starting: "启动中", tunnel_status_failed: "失败", + tunnel_health_no_listener: "无监听", tunnel_form_edit_title: " 编辑 SSH 隧道 ", tunnel_form_field_required: "必填", tunnels_empty: " 无活跃隧道。按 [n] 创建。", @@ -168,6 +170,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "新建", hint_kill_tunnel: "终止", hint_restart_tunnel: "重启", + hint_copy_tunnel: "复制命令", hint_save_tunnels: "保存", hint_reload: "重载", hint_open_tunnel: "隧道", diff --git a/crates/prt-core/src/known_ports.rs b/crates/prt-core/src/known_ports.rs index 397556a..e1ebd1b 100644 --- a/crates/prt-core/src/known_ports.rs +++ b/crates/prt-core/src/known_ports.rs @@ -76,7 +76,11 @@ fn builtin_lookup(port: u16) -> Option<&'static str> { 995 => Some("pop3s"), // ── Common registered ports ────────────────────────────── + // NOTE: proxy/SOCKS labels here are for display only. The suspicious- + // listener heuristic keeps its own narrower `PROXY_PORTS` list in + // `core/suspicious.rs`; the two are intentionally decoupled. 1080 => Some("socks"), + 1081 => Some("socks"), 1194 => Some("openvpn"), 1433 => Some("mssql"), 1434 => Some("mssql-m"), @@ -160,11 +164,13 @@ fn builtin_lookup(port: u16) -> Option<&'static str> { 9000 => Some("php-fpm"), 9042 => Some("cassandra"), 9043 => Some("websphere"), + 9050 => Some("tor-socks"), 9090 => Some("prometheus"), 9091 => Some("transmsn"), 9092 => Some("kafka"), 9093 => Some("alertmgr"), 9100 => Some("node-exp"), + 9150 => Some("tor-socks"), 9160 => Some("cassandra"), 9200 => Some("elastic"), 9300 => Some("elastic"), diff --git a/crates/prt-core/src/model.rs b/crates/prt-core/src/model.rs index 63d4201..5eceae9 100644 --- a/crates/prt-core/src/model.rs +++ b/crates/prt-core/src/model.rs @@ -180,6 +180,8 @@ pub enum SuspiciousReason { ScriptOnSensitive, /// Root process making outgoing connection to a high port. RootHighPortOutgoing, + /// Process listening on a well-known SOCKS/proxy port (1080, 9050, …). + ProxyListening, } /// Column by which the port table can be sorted. diff --git a/crates/prt/Cargo.toml b/crates/prt/Cargo.toml index b471e45..fef84ac 100644 --- a/crates/prt/Cargo.toml +++ b/crates/prt/Cargo.toml @@ -25,3 +25,4 @@ arboard.workspace = true nix.workspace = true ratatui = "0.29" crossterm = "0.28" +shlex = "1" diff --git a/crates/prt/src/app.rs b/crates/prt/src/app.rs index d3ca93f..82455dd 100644 --- a/crates/prt/src/app.rs +++ b/crates/prt/src/app.rs @@ -12,7 +12,7 @@ use prt_core::core::ssh_config::{self, SshHost}; use prt_core::core::ssh_tunnel::SshTunnelSpec; use prt_core::core::{killer, process_detail, session::Session}; use prt_core::i18n; -use prt_core::model::{ProcessesTab, SshTab, TrackedEntry, ViewMode, TICK_RATE}; +use prt_core::model::{ConnectionState, ProcessesTab, SshTab, TrackedEntry, ViewMode, TICK_RATE}; use crate::forward::ForwardManager; use crate::tracer::StraceSession; @@ -20,6 +20,7 @@ use crate::views::action_menu::ActionMenu; use crate::views::command_palette::CommandPalette; use crate::views::tunnel_form::TunnelFormState; use ratatui::prelude::*; +use std::collections::HashSet; use std::io::stdout; use std::time::Instant; @@ -258,6 +259,19 @@ impl App { } } + /// Copy the `ssh` command line of the selected tunnel to the clipboard. + pub fn copy_selected_tunnel_command(&mut self) { + let idx = match self.clamp_tunnels_selected() { + Some(i) => i, + None => return, + }; + let cmd = match self.forwards.tunnels.get(idx) { + Some(t) => t.command_string(), + None => return, + }; + self.copy_to_clipboard(&cmd); + } + /// Persist the current set of active tunnels to the user's config file. /// Failed tunnels are pruned first so the on-disk config stays clean. pub fn save_tunnels(&mut self) { @@ -559,8 +573,19 @@ pub fn run() -> Result<()> { } } - // Cleanup dead tunnels - app.forwards.cleanup(); + // Refresh tunnel statuses from the latest scan (which local ports are + // listening), then auto-reconnect any whose process died (with backoff, + // so an unreachable host isn't hammered). When auto-refresh is paused + // the scan is stale, so the listener health can't be trusted. + let listening: HashSet = app + .session + .entries + .iter() + .filter(|e| e.entry.state == ConnectionState::Listen) + .map(|e| e.entry.local_addr.port()) + .collect(); + app.forwards.cleanup(&listening, !app.auto_refresh_paused); + app.forwards.reconnect_failed(); if last_tick.elapsed() >= TICK_RATE { if !app.auto_refresh_paused { diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index f55eef7..a57b4db 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -5,9 +5,10 @@ use prt_core::core::ssh_config::{SshHost, SshHostSource}; use prt_core::core::ssh_tunnel::{ResolvedHost, SshTunnelSpec, TunnelKind}; +use std::collections::HashSet; use std::process::{Child, Command, Stdio}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Lifecycle status of a tunnel, refreshed on each `cleanup()` tick. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -15,9 +16,64 @@ pub enum TunnelStatus { #[default] Starting, Alive, + /// `ssh` is running but its local port is not actually being listened on + /// (e.g. a `-D`/`-L` bind failure because the port is already in use). + /// Surfaced as a warning; deliberately *not* auto-reconnected because + /// restarting `ssh` won't free a port another process holds. + Unhealthy, Failed, } +/// First delay before retrying a failed auto-reconnect tunnel. +const INITIAL_BACKOFF: Duration = Duration::from_secs(2); +/// Upper bound for the exponential reconnect backoff. +const MAX_BACKOFF: Duration = Duration::from_secs(60); +/// Minimum time a tunnel must stay `Alive` before its reconnect backoff is +/// considered recovered and reset. Must exceed `HEALTH_GRACE` and one scan. +const STABLE_THRESHOLD: Duration = Duration::from_secs(30); +/// Grace period after (re)spawn during which we report `Starting` rather than +/// judging the listener — gives `ssh` time to bind and the scan to observe it. +/// Kept above `TICK_RATE` (2s) so at least one scan happens first. +const HEALTH_GRACE: Duration = Duration::from_secs(3); +/// After this many reconnect attempts without stabilising, give up and mark the +/// tunnel permanently failed (`auto_reconnect = false`) so it can be pruned. +const MAX_RETRIES: u32 = 8; + +/// Next exponential backoff step, capped at [`MAX_BACKOFF`]. +fn next_backoff(cur: Duration) -> Duration { + (cur * 2).min(MAX_BACKOFF) +} + +/// Pure status decision from the inputs `refresh_health` gathers — split out so +/// it can be unit-tested without spawning a real `ssh` child. +/// +/// * dead process → `Failed` +/// * within the spawn grace → `Starting` (don't judge the listener yet) +/// * scan not usable (paused/stale) → `Alive` (avoid a false `no listener`) +/// * listener present → `Alive` +/// * listener absent → `Unhealthy` +fn decide_status( + alive_proc: bool, + uptime: Duration, + listener_ok: bool, + scan_usable: bool, +) -> TunnelStatus { + if !alive_proc { + return TunnelStatus::Failed; + } + if uptime < HEALTH_GRACE { + return TunnelStatus::Starting; + } + if !scan_usable { + return TunnelStatus::Alive; + } + if listener_ok { + TunnelStatus::Alive + } else { + TunnelStatus::Unhealthy + } +} + /// A single SSH tunnel: a running `ssh` child process plus the spec and /// resolved argument list (kept so `restart()` reuses the same resolution). pub struct SshTunnel { @@ -25,21 +81,43 @@ pub struct SshTunnel { args: Vec, child: Child, pub last_status: TunnelStatus, + /// When the current `ssh` child was spawned — reset on `restart()`. + started_at: Instant, + /// Whether to auto-restart this tunnel after it fails on its own. + /// Manual `kill_at` removes the tunnel entirely, so it never reconnects. + pub auto_reconnect: bool, + /// Current exponential backoff between reconnect attempts. + retry_backoff: Duration, + /// Earliest instant the next reconnect attempt may run. `None` once the + /// tunnel is healthy or no retry has been scheduled yet. + next_retry_at: Option, + /// Consecutive reconnect attempts since the tunnel was last stable. + retry_count: u32, } impl SshTunnel { + /// Assemble a freshly-spawned tunnel with default supervision state. + fn from_parts(spec: SshTunnelSpec, args: Vec, child: Child) -> Self { + Self { + spec, + args, + child, + last_status: TunnelStatus::Starting, + started_at: Instant::now(), + auto_reconnect: true, + retry_backoff: INITIAL_BACKOFF, + next_retry_at: None, + retry_count: 0, + } + } + /// Spawn an `ssh` process for `spec` with no extra host resolution /// (relies on `~/.ssh/config` or DNS for the alias). pub fn spawn(spec: SshTunnelSpec) -> Result { spec.validate()?; let args = spec.ssh_args(); let child = spawn_ssh_args(&args)?; - Ok(Self { - spec, - args, - child, - last_status: TunnelStatus::Starting, - }) + Ok(Self::from_parts(spec, args, child)) } /// Spawn an `ssh` process for `spec`. For aliases defined only in prt's @@ -57,12 +135,7 @@ impl SshTunnel { _ => spec.ssh_args(), }; let child = spawn_ssh_args(&args)?; - Ok(Self { - spec, - args, - child, - last_status: TunnelStatus::Starting, - }) + Ok(Self::from_parts(spec, args, child)) } /// Backwards-compat shortcut: spawn a Local tunnel matching the legacy @@ -84,20 +157,23 @@ impl SshTunnel { self.spec.summary() } - /// Refresh `last_status` based on the child process state. - pub fn refresh_status(&mut self) -> TunnelStatus { - let new = match self.child.try_wait() { - Ok(None) => match self.last_status { - TunnelStatus::Starting => { - // After surviving the 150ms spawn check + at least one tick, - // promote to Alive. - TunnelStatus::Alive - } - other => other, - }, - Ok(Some(_)) => TunnelStatus::Failed, - Err(_) => TunnelStatus::Failed, - }; + /// Refresh `last_status` from the child process state and the latest scan. + /// + /// `listening` is the set of local ports currently observed in `LISTEN`; + /// `scan_usable` is false when the scan is paused/stale and can't be + /// trusted to judge the listener. On stabilisation (`Alive` for at least + /// [`STABLE_THRESHOLD`]) the reconnect backoff is reset. + pub fn refresh_health(&mut self, listening: &HashSet, scan_usable: bool) -> TunnelStatus { + let alive_proc = matches!(self.child.try_wait(), Ok(None)); + let listener_ok = listening.contains(&self.spec.local_port); + let new = decide_status(alive_proc, self.uptime(), listener_ok, scan_usable); + + if new == TunnelStatus::Alive && self.uptime() >= STABLE_THRESHOLD { + // Recovered and stable: clear the reconnect penalty. + self.retry_backoff = INITIAL_BACKOFF; + self.retry_count = 0; + self.next_retry_at = None; + } self.last_status = new; new } @@ -108,13 +184,61 @@ impl SshTunnel { let _ = self.child.wait(); } - /// Kill and respawn using the previously resolved arg list. + /// Kill and respawn using the previously resolved arg list (blocking). + /// + /// Used for user-initiated restarts: validates the spawn synchronously so + /// an immediate failure is surfaced, and clears the reconnect penalty since + /// the user explicitly asked to try again. pub fn restart(&mut self) -> Result<(), String> { self.kill(); self.child = spawn_ssh_args(&self.args)?; self.last_status = TunnelStatus::Starting; + self.started_at = Instant::now(); + self.retry_backoff = INITIAL_BACKOFF; + self.retry_count = 0; + self.next_retry_at = None; Ok(()) } + + /// Like [`restart`] but non-blocking: skips the 150ms spawn validation so + /// the auto-reconnect loop never stalls the UI thread. A spawn that dies + /// immediately is caught by the next `refresh_health` tick. Backoff/retry + /// bookkeeping is left to the caller (`reconnect_failed`). + pub fn restart_async(&mut self) -> Result<(), String> { + self.kill(); + self.child = spawn_ssh_args_nowait(&self.args)?; + self.last_status = TunnelStatus::Starting; + self.started_at = Instant::now(); + Ok(()) + } + + /// How long the current `ssh` child has been running. + pub fn uptime(&self) -> Duration { + self.started_at.elapsed() + } + + /// The full `ssh` command line this tunnel was spawned with — handy for + /// copying to the clipboard and reproducing the tunnel outside prt. + /// Arguments are shell-quoted so paths with spaces survive a paste. + pub fn command_string(&self) -> String { + ssh_command_string(&self.args) + } +} + +/// Render `ssh ` with each argument shell-quoted, so an identity path +/// like `/home/u/my keys/id` doesn't split when pasted into a shell. +fn ssh_command_string(args: &[String]) -> String { + let mut cmd = String::from("ssh"); + for arg in args { + cmd.push(' '); + match shlex::try_quote(arg) { + Ok(quoted) => cmd.push_str("ed), + // try_quote only errors on interior NUL, which can't occur in our + // args; fall back to the raw arg to stay infallible. + Err(_) => cmd.push_str(arg), + } + } + cmd } impl Drop for SshTunnel { @@ -132,14 +256,20 @@ fn resolved_from(h: &SshHost) -> ResolvedHost<'_> { } } -fn spawn_ssh_args(args: &[String]) -> Result { - let mut child = Command::new("ssh") +/// Spawn the `ssh` child without any synchronous validation. Non-blocking: +/// safe to call from the render thread (used by auto-reconnect). +fn spawn_ssh_args_nowait(args: &[String]) -> Result { + Command::new("ssh") .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() - .map_err(|e| format!("failed to start ssh: {e}"))?; + .map_err(|e| format!("failed to start ssh: {e}")) +} + +fn spawn_ssh_args(args: &[String]) -> Result { + let mut child = spawn_ssh_args_nowait(args)?; // Quick validation: if ssh exits immediately, surface stderr as an error. thread::sleep(Duration::from_millis(150)); @@ -202,20 +332,67 @@ impl ForwardManager { Ok(self.tunnels.len() - 1) } - /// Refresh each tunnel's `last_status`. Dead tunnels remain in the list - /// (with `last_status = Failed`) so the user can see what happened and - /// either restart or remove them; previously they were silently dropped. - pub fn cleanup(&mut self) { + /// Refresh each tunnel's status from the latest scan. `listening` is the + /// set of local ports currently in `LISTEN`; `scan_usable` is false when + /// the scan is paused/stale. Dead tunnels stay in the list (as `Failed`) + /// so the user can see what happened and restart or remove them. + pub fn cleanup(&mut self, listening: &HashSet, scan_usable: bool) { for tunnel in &mut self.tunnels { - tunnel.refresh_status(); + tunnel.refresh_health(listening, scan_usable); } } - /// Drop tunnels that have already failed. Called when the user asks to - /// prune the list (e.g. via "save" which only persists running tunnels). + /// Auto-restart tunnels whose process died on their own, with exponential + /// backoff so an unreachable host isn't hammered. Call after `cleanup()`. + /// Returns the number of restart attempts that spawned this tick. + /// + /// Backoff grows on *every* attempt (not just hard failures), so a host + /// that accepts the connection but drops it seconds later still backs off. + /// The penalty is cleared only once a tunnel stays `Alive` for + /// [`STABLE_THRESHOLD`] (handled in `refresh_health`). After [`MAX_RETRIES`] + /// attempts without recovery the tunnel is marked permanently failed + /// (`auto_reconnect = false`) so it stops retrying and can be pruned. + /// + /// Only `Failed` (dead process) is reconnected — `Unhealthy` is left alone + /// because restarting `ssh` can't free a port another process holds. + pub fn reconnect_failed(&mut self) -> usize { + let now = Instant::now(); + let mut reconnected = 0; + for tunnel in &mut self.tunnels { + if tunnel.last_status != TunnelStatus::Failed || !tunnel.auto_reconnect { + continue; + } + match tunnel.next_retry_at { + // No attempt scheduled yet: schedule the first one and wait. + None => tunnel.next_retry_at = Some(now + tunnel.retry_backoff), + // Scheduled but not yet due: keep waiting. + Some(at) if at > now => {} + // Due: attempt a non-blocking restart and grow the backoff. + Some(_) => { + tunnel.retry_count += 1; + tunnel.retry_backoff = next_backoff(tunnel.retry_backoff); + tunnel.next_retry_at = Some(now + tunnel.retry_backoff); + if tunnel.restart_async().is_ok() { + reconnected += 1; + } + if tunnel.retry_count >= MAX_RETRIES { + tunnel.auto_reconnect = false; + } + } + } + } + reconnected + } + + /// Drop tunnels that are permanently dead. A `Failed` tunnel that is still + /// auto-reconnecting is kept (it's live config the user expects to persist); + /// only those that exhausted their retries (`auto_reconnect == false`) are + /// pruned. Called before `save_tunnels` persists the surviving specs. pub fn drop_failed(&mut self) { + // Keep everything except permanently-dead tunnels (failed *and* no + // longer auto-reconnecting). self.tunnels - .retain(|t| t.last_status != TunnelStatus::Failed); + .retain(|t| t.last_status != TunnelStatus::Failed || t.auto_reconnect); } /// Kill the tunnel at `idx`. No-op if out of bounds. @@ -306,4 +483,65 @@ mod tests { // Tunnel creation tests require an actual SSH server, so we only test // the manager state logic here. The spec-level tests cover argument // generation in `prt_core::core::ssh_tunnel`. + + // ── decide_status (pure) ───────────────────────────────────── + + #[test] + fn decide_status_dead_process_is_failed() { + let s = decide_status(false, Duration::from_secs(100), true, true); + assert_eq!(s, TunnelStatus::Failed); + } + + #[test] + fn decide_status_within_grace_is_starting() { + let s = decide_status(true, Duration::from_secs(1), false, true); + assert_eq!(s, TunnelStatus::Starting); + } + + #[test] + fn decide_status_paused_scan_assumes_alive() { + // No usable scan → don't emit a false "no listener". + let s = decide_status(true, Duration::from_secs(100), false, false); + assert_eq!(s, TunnelStatus::Alive); + } + + #[test] + fn decide_status_listener_present_is_alive() { + let s = decide_status(true, Duration::from_secs(100), true, true); + assert_eq!(s, TunnelStatus::Alive); + } + + #[test] + fn decide_status_listener_absent_is_unhealthy() { + let s = decide_status(true, Duration::from_secs(100), false, true); + assert_eq!(s, TunnelStatus::Unhealthy); + } + + // ── next_backoff (pure) ────────────────────────────────────── + + #[test] + fn next_backoff_doubles_then_caps() { + assert_eq!(next_backoff(INITIAL_BACKOFF), Duration::from_secs(4)); + assert_eq!(next_backoff(Duration::from_secs(4)), Duration::from_secs(8)); + assert_eq!(next_backoff(Duration::from_secs(32)), MAX_BACKOFF); + // Never exceeds the cap. + assert_eq!(next_backoff(MAX_BACKOFF), MAX_BACKOFF); + } + + // ── ssh_command_string (pure) ──────────────────────────────── + + #[test] + fn command_string_quotes_spaced_args() { + let args = vec![ + "-N".to_string(), + "-i".to_string(), + "/home/u/my keys/id_rsa".to_string(), + "prod".to_string(), + ]; + let cmd = ssh_command_string(&args); + // The spaced path must be quoted so a paste stays one argument. + assert!(cmd.contains("'/home/u/my keys/id_rsa'")); + assert!(cmd.starts_with("ssh -N -i ")); + assert!(cmd.ends_with(" prod")); + } } diff --git a/crates/prt/src/ui.rs b/crates/prt/src/ui.rs index 02bf617..e8a438a 100644 --- a/crates/prt/src/ui.rs +++ b/crates/prt/src/ui.rs @@ -1180,6 +1180,7 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) { items.push(("e", s.hint_edit_tunnel)); items.push(("K", s.hint_kill_tunnel)); items.push(("r", s.hint_restart_tunnel)); + items.push(("c", s.hint_copy_tunnel)); items.push(("s", s.hint_save_tunnels)); } } diff --git a/crates/prt/src/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index d821ecd..10505f6 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -3,6 +3,7 @@ use crate::app::App; use crate::forward::TunnelStatus; use crossterm::event::{KeyCode, KeyEvent}; +use prt_core::core::scanner::format_duration; use prt_core::core::ssh_tunnel::TunnelKind; use prt_core::i18n; use ratatui::prelude::*; @@ -32,6 +33,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { Cell::from(s.tunnel_col_local), Cell::from(s.tunnel_col_remote), Cell::from(s.tunnel_col_host), + Cell::from(s.tunnel_col_uptime), Cell::from(s.tunnel_col_status), ]) .style( @@ -61,18 +63,30 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { ), TunnelKind::Dynamic => "(SOCKS5)".into(), }; + // Status comes straight from the model. `Unhealthy` means the ssh + // child is alive but its local port isn't being listened on (a + // broken `-D`/`-L` bind) — shown as a yellow warning rather than a + // misleading green "alive". let (status, color) = match t.last_status { TunnelStatus::Alive => (s.tunnel_status_alive, Color::Green), TunnelStatus::Starting => (s.tunnel_status_starting, Color::Yellow), + TunnelStatus::Unhealthy => (s.tunnel_health_no_listener, Color::Yellow), TunnelStatus::Failed => (s.tunnel_status_failed, Color::Red), }; + // Uptime is only meaningful while the child is running. + let uptime = match t.last_status { + TunnelStatus::Failed => "-".to_string(), + _ => format_duration(t.uptime()), + }; + Row::new(vec![ Cell::from(t.spec.name.clone().unwrap_or_else(|| "-".into())), Cell::from(kind_label), Cell::from(local), Cell::from(remote), Cell::from(t.spec.host_alias.clone()), + Cell::from(uptime), Cell::from(status).style(Style::default().fg(color)), ]) }) @@ -85,6 +99,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { Constraint::Fill(1), Constraint::Length(16), Constraint::Length(8), + Constraint::Length(12), ]; let table = Table::new(rows, widths) .header(header) @@ -139,6 +154,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { app.restart_selected_tunnel(); true } + KeyCode::Char('c') => { + app.copy_selected_tunnel_command(); + true + } KeyCode::Char('s') => { app.save_tunnels(); true