From 96dc377c83e2044f4230c122e583e77b1cbd0ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:53:27 +0000 Subject: [PATCH 1/4] feat(core): flag local SOCKS/proxy listeners as suspicious Add a ProxyListening heuristic that flags processes listening on well-known SOCKS/proxy ports (1080, 1081, 3128, 9050, 9150). 8080/8443 are deliberately excluded to avoid false positives on http-alt servers. The new reason plugs into the existing enrichment pipeline, so proxy listeners get the magenta + [!] treatment and match the 'suspicious' filter automatically. Also adds tor-socks/socks names to known_ports. --- crates/prt-core/src/core/suspicious.rs | 54 ++++++++++++++++++++++++++ crates/prt-core/src/known_ports.rs | 3 ++ crates/prt-core/src/model.rs | 2 + 3 files changed, 59 insertions(+) diff --git a/crates/prt-core/src/core/suspicious.rs b/crates/prt-core/src/core/suspicious.rs index 60a3139..7902edf 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,17 @@ 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. +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 +43,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 +98,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 +317,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/known_ports.rs b/crates/prt-core/src/known_ports.rs index 397556a..e6850f0 100644 --- a/crates/prt-core/src/known_ports.rs +++ b/crates/prt-core/src/known_ports.rs @@ -77,6 +77,7 @@ fn builtin_lookup(port: u16) -> Option<&'static str> { // ── Common registered ports ────────────────────────────── 1080 => Some("socks"), + 1081 => Some("socks"), 1194 => Some("openvpn"), 1433 => Some("mssql"), 1434 => Some("mssql-m"), @@ -160,11 +161,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. From dd8e2717271b6d06214dd8937d73d67d79b8caf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:57:11 +0000 Subject: [PATCH 2/4] feat(tui): tunnels uptime column, listener health, copy ssh command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an Uptime column (compact 45s/12m/3h04m/2d05h formatting), reset on restart. - Layer a listener health check over the Alive status: an alive ssh child with no local listener on its port (broken -D/-L bind) now shows a yellow 'no listener' warning instead of a misleading green 'alive'. Reuses already-scanned LISTEN entries — opens no new connections. - Add 'c' key to copy the tunnel's full ssh command to the clipboard, reusing the existing copy_to_clipboard helper. - New i18n strings (uptime column, no-listener, copy hint) in en/ru/zh. --- crates/prt-core/src/i18n/en.rs | 3 ++ crates/prt-core/src/i18n/mod.rs | 3 ++ crates/prt-core/src/i18n/ru.rs | 3 ++ crates/prt-core/src/i18n/zh.rs | 3 ++ crates/prt/src/app.rs | 13 ++++++++ crates/prt/src/forward.rs | 23 +++++++++++++- crates/prt/src/ui.rs | 1 + crates/prt/src/views/tunnels.rs | 54 +++++++++++++++++++++++++++++++-- 8 files changed, 99 insertions(+), 4 deletions(-) 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/src/app.rs b/crates/prt/src/app.rs index d3ca93f..ab41571 100644 --- a/crates/prt/src/app.rs +++ b/crates/prt/src/app.rs @@ -258,6 +258,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) { diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index f55eef7..db93933 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -7,7 +7,7 @@ use prt_core::core::ssh_config::{SshHost, SshHostSource}; use prt_core::core::ssh_tunnel::{ResolvedHost, SshTunnelSpec, TunnelKind}; 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)] @@ -25,6 +25,8 @@ pub struct SshTunnel { args: Vec, child: Child, pub last_status: TunnelStatus, + /// When the current `ssh` child was spawned — reset on `restart()`. + started_at: Instant, } impl SshTunnel { @@ -39,6 +41,7 @@ impl SshTunnel { args, child, last_status: TunnelStatus::Starting, + started_at: Instant::now(), }) } @@ -62,6 +65,7 @@ impl SshTunnel { args, child, last_status: TunnelStatus::Starting, + started_at: Instant::now(), }) } @@ -113,8 +117,25 @@ impl SshTunnel { self.kill(); self.child = spawn_ssh_args(&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. + pub fn command_string(&self) -> String { + let mut cmd = String::from("ssh"); + for arg in &self.args { + cmd.push(' '); + cmd.push_str(arg); + } + cmd + } } impl Drop for SshTunnel { 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..3341e4c 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -5,8 +5,33 @@ use crate::forward::TunnelStatus; use crossterm::event::{KeyCode, KeyEvent}; use prt_core::core::ssh_tunnel::TunnelKind; use prt_core::i18n; +use prt_core::model::ConnectionState; use ratatui::prelude::*; use ratatui::widgets::*; +use std::time::Duration; + +/// Compact uptime: `45s`, `12m`, `3h04m`, `2d05h`. +fn fmt_uptime(d: Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86_400 { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } else { + format!("{}d{:02}h", secs / 86_400, (secs % 86_400) / 3600) + } +} + +/// True if a local listener is currently bound to `local_port` in the latest +/// scan — confirms an `Alive` tunnel actually opened its socket. Read-only: +/// reuses the data prt already scanned, opens no new connections. +fn has_local_listener(app: &App, local_port: u16) -> bool { + app.session.entries.iter().any(|e| { + e.entry.state == ConnectionState::Listen && e.entry.local_addr.port() == local_port + }) +} pub fn draw(f: &mut Frame, app: &App, area: Rect) { let s = i18n::strings(); @@ -32,6 +57,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,10 +87,26 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { ), TunnelKind::Dynamic => "(SOCKS5)".into(), }; + // Status, with a listener health check layered on top: an `Alive` + // ssh child whose local port isn't actually being listened on is a + // common sign of a broken `-D`/`-L` (e.g. bind failure), so surface + // it 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::Failed => (s.tunnel_status_failed, Color::Red), + TunnelStatus::Alive => { + if has_local_listener(app, t.spec.local_port) { + (s.tunnel_status_alive.to_string(), Color::Green) + } else { + (s.tunnel_health_no_listener.to_string(), Color::Yellow) + } + } + TunnelStatus::Starting => (s.tunnel_status_starting.to_string(), Color::Yellow), + TunnelStatus::Failed => (s.tunnel_status_failed.to_string(), Color::Red), + }; + + // Uptime is only meaningful while the child is running. + let uptime = match t.last_status { + TunnelStatus::Failed => "-".to_string(), + _ => fmt_uptime(t.uptime()), }; Row::new(vec![ @@ -73,6 +115,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { 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 +128,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 +183,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 From c520aee0ee9f469fb1c38b207237ec28bbb879ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 17:00:10 +0000 Subject: [PATCH 3/4] feat(tui): auto-reconnect failed SSH tunnels with backoff Tunnels that die on their own are now automatically restarted with exponential backoff (2s doubling up to 60s) so an unreachable host isn't hammered. reconnect_failed() runs each loop iteration right after cleanup(); gating is by next_retry_at rather than the tick, and a successful restart resets the backoff. Manual kill removes a tunnel entirely, so only self-inflicted failures reconnect. --- crates/prt/src/app.rs | 4 ++- crates/prt/src/forward.rs | 54 +++++++++++++++++++++++++++++++++ crates/prt/src/views/tunnels.rs | 18 +++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/prt/src/app.rs b/crates/prt/src/app.rs index ab41571..07a3250 100644 --- a/crates/prt/src/app.rs +++ b/crates/prt/src/app.rs @@ -572,8 +572,10 @@ pub fn run() -> Result<()> { } } - // Cleanup dead tunnels + // Refresh tunnel statuses, then auto-reconnect any that died on their + // own (with backoff, so an unreachable host isn't hammered). app.forwards.cleanup(); + 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 db93933..8b1fd11 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -18,6 +18,11 @@ pub enum TunnelStatus { 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); + /// 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 { @@ -27,6 +32,14 @@ pub struct SshTunnel { 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, } impl SshTunnel { @@ -42,6 +55,9 @@ impl SshTunnel { child, last_status: TunnelStatus::Starting, started_at: Instant::now(), + auto_reconnect: true, + retry_backoff: INITIAL_BACKOFF, + next_retry_at: None, }) } @@ -66,6 +82,9 @@ impl SshTunnel { child, last_status: TunnelStatus::Starting, started_at: Instant::now(), + auto_reconnect: true, + retry_backoff: INITIAL_BACKOFF, + next_retry_at: None, }) } @@ -118,6 +137,8 @@ impl SshTunnel { self.child = spawn_ssh_args(&self.args)?; self.last_status = TunnelStatus::Starting; self.started_at = Instant::now(); + self.retry_backoff = INITIAL_BACKOFF; + self.next_retry_at = None; Ok(()) } @@ -232,6 +253,39 @@ impl ForwardManager { } } + /// Auto-restart tunnels that have failed on their own, with exponential + /// backoff so an unreachable host isn't hammered. Call after `cleanup()` + /// (which is what marks tunnels `Failed`). Returns the number of restart + /// attempts that succeeded this tick. + /// + /// Scheduling: the first time a failure is observed, the next attempt is + /// deferred by `INITIAL_BACKOFF`; each failed attempt doubles the delay up + /// to `MAX_BACKOFF`; a successful restart resets the backoff via `restart`. + 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: try to restart. + Some(_) => match tunnel.restart() { + Ok(()) => reconnected += 1, // restart() resets the backoff + Err(_) => { + tunnel.retry_backoff = (tunnel.retry_backoff * 2).min(MAX_BACKOFF); + tunnel.next_retry_at = Some(now + tunnel.retry_backoff); + } + }, + } + } + reconnected + } + /// Drop tunnels that have already failed. Called when the user asks to /// prune the list (e.g. via "save" which only persists running tunnels). pub fn drop_failed(&mut self) { diff --git a/crates/prt/src/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index 3341e4c..f53aab4 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -194,3 +194,21 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fmt_uptime_buckets() { + assert_eq!(fmt_uptime(Duration::from_secs(0)), "0s"); + assert_eq!(fmt_uptime(Duration::from_secs(45)), "45s"); + assert_eq!(fmt_uptime(Duration::from_secs(59)), "59s"); + assert_eq!(fmt_uptime(Duration::from_secs(60)), "1m"); + assert_eq!(fmt_uptime(Duration::from_secs(3599)), "59m"); + assert_eq!(fmt_uptime(Duration::from_secs(3600)), "1h00m"); + assert_eq!(fmt_uptime(Duration::from_secs(3600 + 4 * 60)), "1h04m"); + assert_eq!(fmt_uptime(Duration::from_secs(86_400)), "1d00h"); + assert_eq!(fmt_uptime(Duration::from_secs(86_400 + 5 * 3600)), "1d05h"); + } +} From 0074f3e4405b8aab3b4f44cb65d74e3c88a57f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:43:07 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(tui):=20address=20PR=20#12=20review=20?= =?UTF-8?q?=E2=80=94=20real=20backoff,=20non-blocking=20reconnect,=20model?= =?UTF-8?q?=20health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-reconnect (#1,#2,#3,#8): - Backoff now actually grows for unreachable hosts. The reset was tied to a 'successful' 150ms spawn, which an unreachable host passes (it blocks on TCP timeout), so the delay never increased. Growth now happens on every attempt and is reset only after a tunnel stays Alive for STABLE_THRESHOLD (30s), via refresh_health. - reconnect uses a new non-blocking restart_async() (no 150ms sleep in the render thread); failures are caught on the next tick. Manual restart() stays blocking for immediate feedback. - After MAX_RETRIES attempts without recovery a tunnel is marked permanently failed (auto_reconnect=false); drop_failed() now prunes only those, so 'save' no longer discards a tunnel that's mid-reconnect. auto_reconnect is now a meaningful flag. Listener health in the model (#4,#5): - New TunnelStatus::Unhealthy (process alive, local port not listening). Decision extracted to a pure, unit-tested decide_status(); a HEALTH_GRACE window and a scan_usable flag suppress false 'no listener' right after start and while auto-refresh is paused. cleanup() now takes the listening port set; the view no longer reaches into the scan. Cleanups: - command_string() shell-quotes args via shlex (#6). - uptime column reuses prt-core format_duration (#7). - private from_parts() constructor removes spawn duplication (#9). - cross-reference comments between PROXY_PORTS and known_ports (#10). New unit tests: decide_status, next_backoff, command_string quoting. --- Cargo.lock | 1 + crates/prt-core/src/core/suspicious.rs | 5 + crates/prt-core/src/known_ports.rs | 3 + crates/prt/Cargo.toml | 1 + crates/prt/src/app.rs | 18 +- crates/prt/src/forward.rs | 289 +++++++++++++++++++------ crates/prt/src/views/tunnels.rs | 67 +----- 7 files changed, 260 insertions(+), 124 deletions(-) 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 7902edf..cef85c8 100644 --- a/crates/prt-core/src/core/suspicious.rs +++ b/crates/prt-core/src/core/suspicious.rs @@ -22,6 +22,11 @@ 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) diff --git a/crates/prt-core/src/known_ports.rs b/crates/prt-core/src/known_ports.rs index e6850f0..e1ebd1b 100644 --- a/crates/prt-core/src/known_ports.rs +++ b/crates/prt-core/src/known_ports.rs @@ -76,6 +76,9 @@ 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"), 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 07a3250..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; @@ -572,9 +573,18 @@ pub fn run() -> Result<()> { } } - // Refresh tunnel statuses, then auto-reconnect any that died on their - // own (with backoff, so an unreachable host isn't hammered). - 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 { diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index 8b1fd11..a57b4db 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -5,6 +5,7 @@ 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, Instant}; @@ -15,6 +16,11 @@ 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, } @@ -22,6 +28,51 @@ pub enum TunnelStatus { 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). @@ -40,16 +91,14 @@ pub struct SshTunnel { /// 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 { - /// 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 { + /// Assemble a freshly-spawned tunnel with default supervision state. + fn from_parts(spec: SshTunnelSpec, args: Vec, child: Child) -> Self { + Self { spec, args, child, @@ -58,7 +107,17 @@ impl SshTunnel { 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::from_parts(spec, args, child)) } /// Spawn an `ssh` process for `spec`. For aliases defined only in prt's @@ -76,16 +135,7 @@ impl SshTunnel { _ => spec.ssh_args(), }; let child = spawn_ssh_args(&args)?; - Ok(Self { - spec, - args, - child, - last_status: TunnelStatus::Starting, - started_at: Instant::now(), - auto_reconnect: true, - retry_backoff: INITIAL_BACKOFF, - next_retry_at: None, - }) + Ok(Self::from_parts(spec, args, child)) } /// Backwards-compat shortcut: spawn a Local tunnel matching the legacy @@ -107,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 } @@ -131,17 +184,34 @@ 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() @@ -149,14 +219,26 @@ impl SshTunnel { /// 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 { - let mut cmd = String::from("ssh"); - for arg in &self.args { - cmd.push(' '); - cmd.push_str(arg); + 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 } + cmd } impl Drop for SshTunnel { @@ -174,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)); @@ -244,23 +332,29 @@ 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); } } - /// Auto-restart tunnels that have failed on their own, with exponential - /// backoff so an unreachable host isn't hammered. Call after `cleanup()` - /// (which is what marks tunnels `Failed`). Returns the number of restart - /// attempts that succeeded this tick. + /// 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. /// - /// Scheduling: the first time a failure is observed, the next attempt is - /// deferred by `INITIAL_BACKOFF`; each failed attempt doubles the delay up - /// to `MAX_BACKOFF`; a successful restart resets the backoff via `restart`. + /// 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; @@ -273,24 +367,32 @@ impl ForwardManager { None => tunnel.next_retry_at = Some(now + tunnel.retry_backoff), // Scheduled but not yet due: keep waiting. Some(at) if at > now => {} - // Due: try to restart. - Some(_) => match tunnel.restart() { - Ok(()) => reconnected += 1, // restart() resets the backoff - Err(_) => { - tunnel.retry_backoff = (tunnel.retry_backoff * 2).min(MAX_BACKOFF); - tunnel.next_retry_at = Some(now + tunnel.retry_backoff); + // 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 have already failed. Called when the user asks to - /// prune the list (e.g. via "save" which only persists running tunnels). + /// 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. @@ -381,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/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index f53aab4..10505f6 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -3,35 +3,11 @@ 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 prt_core::model::ConnectionState; use ratatui::prelude::*; use ratatui::widgets::*; -use std::time::Duration; - -/// Compact uptime: `45s`, `12m`, `3h04m`, `2d05h`. -fn fmt_uptime(d: Duration) -> String { - let secs = d.as_secs(); - if secs < 60 { - format!("{secs}s") - } else if secs < 3600 { - format!("{}m", secs / 60) - } else if secs < 86_400 { - format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) - } else { - format!("{}d{:02}h", secs / 86_400, (secs % 86_400) / 3600) - } -} - -/// True if a local listener is currently bound to `local_port` in the latest -/// scan — confirms an `Alive` tunnel actually opened its socket. Read-only: -/// reuses the data prt already scanned, opens no new connections. -fn has_local_listener(app: &App, local_port: u16) -> bool { - app.session.entries.iter().any(|e| { - e.entry.state == ConnectionState::Listen && e.entry.local_addr.port() == local_port - }) -} pub fn draw(f: &mut Frame, app: &App, area: Rect) { let s = i18n::strings(); @@ -87,26 +63,21 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { ), TunnelKind::Dynamic => "(SOCKS5)".into(), }; - // Status, with a listener health check layered on top: an `Alive` - // ssh child whose local port isn't actually being listened on is a - // common sign of a broken `-D`/`-L` (e.g. bind failure), so surface - // it as a yellow warning rather than a misleading green "alive". + // 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 => { - if has_local_listener(app, t.spec.local_port) { - (s.tunnel_status_alive.to_string(), Color::Green) - } else { - (s.tunnel_health_no_listener.to_string(), Color::Yellow) - } - } - TunnelStatus::Starting => (s.tunnel_status_starting.to_string(), Color::Yellow), - TunnelStatus::Failed => (s.tunnel_status_failed.to_string(), Color::Red), + 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(), - _ => fmt_uptime(t.uptime()), + _ => format_duration(t.uptime()), }; Row::new(vec![ @@ -194,21 +165,3 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { _ => false, } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fmt_uptime_buckets() { - assert_eq!(fmt_uptime(Duration::from_secs(0)), "0s"); - assert_eq!(fmt_uptime(Duration::from_secs(45)), "45s"); - assert_eq!(fmt_uptime(Duration::from_secs(59)), "59s"); - assert_eq!(fmt_uptime(Duration::from_secs(60)), "1m"); - assert_eq!(fmt_uptime(Duration::from_secs(3599)), "59m"); - assert_eq!(fmt_uptime(Duration::from_secs(3600)), "1h00m"); - assert_eq!(fmt_uptime(Duration::from_secs(3600 + 4 * 60)), "1h04m"); - assert_eq!(fmt_uptime(Duration::from_secs(86_400)), "1d00h"); - assert_eq!(fmt_uptime(Duration::from_secs(86_400 + 5 * 3600)), "1d05h"); - } -}