From 96dc377c83e2044f4230c122e583e77b1cbd0ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:53:27 +0000 Subject: [PATCH 1/6] 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/6] 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/6] 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 f1b91387aa0c0a4c41e449201d66c9d2e5847082 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:35:51 +0000 Subject: [PATCH 4/6] fix(tui): address review of tunnel auto-reconnect, uptime & SOCKS detect Fixes the defects raised in code review of the auto-reconnect / uptime / SOCKS-detection work: Critical - Exponential backoff now actually grows for an unreachable host. The reset no longer happens on every respawn (where `ssh` survives the 150ms spawn check but dies seconds later on TCP timeout); it moves to refresh_status and only fires once the tunnel has stayed Alive for STABILITY_THRESHOLD (30s). Backoff doubles per attempt up to MAX_BACKOFF. - Auto-reconnect no longer blocks the UI thread: it respawns via a non-blocking spawn_ssh_args_nowait (no 150ms liveness sleep). The validating spawn is kept for the interactive path (initial spawn + manual restart). - save no longer silently deletes a tunnel that's mid-reconnect: drop_failed prunes only tunnels that are Failed AND no longer auto-reconnecting. Medium - No more false "no listener": within a grace window after (re)start, or while auto-refresh is paused (stale scan), trust the Alive status instead. - Documented that the listener health check is advisory/view-only and not acted on by the reconnect loop. - command_string now shell-quotes each argument so identity paths with spaces paste back as a single argument. Cleanup - format_uptime moved into prt-core scanner next to format_duration (dedup). - auto_reconnect is now a live flag: cleared after MAX_RECONNECT_ATTEMPTS so a permanently unreachable host stops being retried; re-enabled on manual restart. - Single private from_child constructor shared by spawn/spawn_with_host. - Cross-referenced PROXY_PORTS and known_ports so the two lists stay in sync. Adds shell_quote unit tests; moves the fmt_uptime test to scanner as format_uptime_buckets. cargo test/clippy/fmt all clean. https://claude.ai/code/session_01V2L7zMrNT4vcJFjAHyuShp --- crates/prt-core/src/core/scanner.rs | 32 ++++ crates/prt-core/src/core/suspicious.rs | 5 + crates/prt-core/src/known_ports.rs | 5 + crates/prt/src/forward.rs | 209 ++++++++++++++++++++----- crates/prt/src/views/tunnels.rs | 53 +++---- 5 files changed, 235 insertions(+), 69 deletions(-) diff --git a/crates/prt-core/src/core/scanner.rs b/crates/prt-core/src/core/scanner.rs index c338ab9..8c6604d 100644 --- a/crates/prt-core/src/core/scanner.rs +++ b/crates/prt-core/src/core/scanner.rs @@ -144,6 +144,22 @@ pub fn format_duration(d: Duration) -> String { } } +/// Like [`format_duration`] but keeps the finer unit for longer spans: +/// `45s`, `12m`, `3h04m`, `2d05h`. Used for tunnel uptime, where minute/hour +/// precision is more useful than [`format_duration`]'s coarser buckets. +pub fn format_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) + } +} + /// Sort entries in-place by the given column and direction. pub fn sort_entries(entries: &mut [TrackedEntry], state: &SortState) { entries.sort_by(|a, b| { @@ -1231,6 +1247,22 @@ mod tests { } } + #[test] + fn format_uptime_buckets() { + assert_eq!(format_uptime(Duration::from_secs(0)), "0s"); + assert_eq!(format_uptime(Duration::from_secs(45)), "45s"); + assert_eq!(format_uptime(Duration::from_secs(59)), "59s"); + assert_eq!(format_uptime(Duration::from_secs(60)), "1m"); + assert_eq!(format_uptime(Duration::from_secs(3599)), "59m"); + assert_eq!(format_uptime(Duration::from_secs(3600)), "1h00m"); + assert_eq!(format_uptime(Duration::from_secs(3600 + 4 * 60)), "1h04m"); + assert_eq!(format_uptime(Duration::from_secs(86_400)), "1d00h"); + assert_eq!( + format_uptime(Duration::from_secs(86_400 + 5 * 3600)), + "1d05h" + ); + } + // ── build_process_tree ──────────────────────────────────────── #[test] diff --git a/crates/prt-core/src/core/suspicious.rs b/crates/prt-core/src/core/suspicious.rs index 7902edf..1ed2c82 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. +/// +/// This is intentionally a separate, tighter list from +/// `crate::known_ports::known_service` (which names many proxy ports for +/// display); keep the two in sync by hand when adding a proxy port that should +/// also drive this heuristic. 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..ce7f7d0 100644 --- a/crates/prt-core/src/known_ports.rs +++ b/crates/prt-core/src/known_ports.rs @@ -2,6 +2,11 @@ //! //! Provides a compile-time lookup table for ~170 common ports. //! User overrides from `~/.config/prt/config.toml` take precedence. +//! +//! NOTE: the proxy/SOCKS ports here are for *display* naming only. The +//! suspicious-listener heuristic uses its own intentionally narrower list, +//! `crate::core::suspicious::PROXY_PORTS` — adding a proxy port here does not +//! automatically flag it as suspicious. use std::collections::HashMap; diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index 8b1fd11..c1eb53b 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -22,6 +22,19 @@ 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); +/// How long a tunnel must stay `Alive` before it's considered genuinely +/// recovered and its backoff is reset. Without this, a tunnel to an +/// unreachable host (where `ssh` survives the brief spawn check but dies a few +/// seconds later on TCP timeout) would have its backoff reset on every respawn, +/// defeating the exponential growth and hammering the host every couple of +/// seconds. The reset therefore lives in `refresh_status`, gated on uptime — +/// not in the respawn path. +const STABILITY_THRESHOLD: Duration = Duration::from_secs(30); +/// Give up auto-reconnecting after this many consecutive failed attempts so a +/// permanently unreachable host isn't retried forever. The tunnel stays +/// `Failed` (and `auto_reconnect` flips to `false`), which lets `save`/prune +/// remove it and lets the user restart it manually. +const MAX_RECONNECT_ATTEMPTS: u32 = 10; /// A single SSH tunnel: a running `ssh` child process plus the spec and /// resolved argument list (kept so `restart()` reuses the same resolution). @@ -34,22 +47,23 @@ pub struct SshTunnel { 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. + /// Flipped to `false` once `MAX_RECONNECT_ATTEMPTS` is exhausted. pub auto_reconnect: bool, /// Current exponential backoff between reconnect attempts. retry_backoff: Duration, + /// Consecutive failed reconnect attempts; reset once the tunnel is stably + /// `Alive`. Drives the give-up at `MAX_RECONNECT_ATTEMPTS`. + retry_count: u32, /// 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 { - /// 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 tunnel from a freshly spawned child. Single place that seeds + /// the reconnect/uptime bookkeeping so new fields only need adding once. + fn from_child(spec: SshTunnelSpec, args: Vec, child: Child) -> Self { + Self { spec, args, child, @@ -57,8 +71,18 @@ impl SshTunnel { started_at: Instant::now(), auto_reconnect: true, retry_backoff: INITIAL_BACKOFF, + retry_count: 0, next_retry_at: None, - }) + } + } + + /// 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_child(spec, args, child)) } /// Spawn an `ssh` process for `spec`. For aliases defined only in prt's @@ -76,16 +100,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_child(spec, args, child)) } /// Backwards-compat shortcut: spawn a Local tunnel matching the legacy @@ -116,6 +131,20 @@ impl SshTunnel { // promote to Alive. TunnelStatus::Alive } + TunnelStatus::Alive => { + // Only a sustained `Alive` period proves the tunnel really + // came up (an unreachable host keeps `ssh` alive for a few + // seconds before TCP timeout). Reset the backoff here rather + // than on respawn so the exponential growth survives a host + // that flaps every few seconds. + if self.started_at.elapsed() >= STABILITY_THRESHOLD { + self.retry_backoff = INITIAL_BACKOFF; + self.retry_count = 0; + self.next_retry_at = None; + } + TunnelStatus::Alive + } + // `Failed` with a still-running child shouldn't happen. other => other, }, Ok(Some(_)) => TunnelStatus::Failed, @@ -131,14 +160,32 @@ impl SshTunnel { let _ = self.child.wait(); } - /// Kill and respawn using the previously resolved arg list. - pub fn restart(&mut self) -> Result<(), String> { + /// Kill the current child and spawn a fresh one with the same arg list. + /// `validate` runs the brief blocking liveness check (good for interactive + /// restart, which wants immediate error feedback); auto-reconnect passes + /// `false` so it never blocks the UI thread on the spawn sleep. + fn respawn(&mut self, validate: bool) -> Result<(), String> { self.kill(); - self.child = spawn_ssh_args(&self.args)?; + self.child = if validate { + spawn_ssh_args(&self.args)? + } else { + spawn_ssh_args_nowait(&self.args)? + }; self.last_status = TunnelStatus::Starting; self.started_at = Instant::now(); + Ok(()) + } + + /// Manual restart: kill and respawn, then wipe the reconnect bookkeeping so + /// the user's explicit action gives the tunnel a clean slate. + pub fn restart(&mut self) -> Result<(), String> { + self.respawn(true)?; self.retry_backoff = INITIAL_BACKOFF; + self.retry_count = 0; self.next_retry_at = None; + // Re-engage auto-reconnect: a manual restart means the user wants this + // tunnel back, even if a prior run had exhausted its retries. + self.auto_reconnect = true; Ok(()) } @@ -148,17 +195,47 @@ impl SshTunnel { } /// The full `ssh` command line this tunnel was spawned with — handy for - /// copying to the clipboard and reproducing the tunnel outside prt. + /// copying to the clipboard and reproducing the tunnel outside prt. Each + /// argument is shell-quoted so paths with spaces (e.g. an identity file + /// under `/Users/x/my keys/id_rsa`) paste back as a single argument. pub fn command_string(&self) -> String { let mut cmd = String::from("ssh"); for arg in &self.args { cmd.push(' '); - cmd.push_str(arg); + cmd.push_str(&shell_quote(arg)); } cmd } } +/// Quote a single argument for safe pasting into a POSIX shell. Returns the +/// argument unchanged when it contains only shell-safe characters; otherwise +/// wraps it in single quotes, escaping any embedded single quote as `'\''`. +fn shell_quote(arg: &str) -> String { + let safe = !arg.is_empty() + && arg.bytes().all(|b| { + b.is_ascii_alphanumeric() + || matches!( + b, + b'_' | b'-' | b'.' | b'/' | b':' | b'=' | b'@' | b',' | b'+' + ) + }); + if safe { + return arg.to_string(); + } + let mut out = String::with_capacity(arg.len() + 2); + out.push('\''); + for ch in arg.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + impl Drop for SshTunnel { fn drop(&mut self) { self.kill(); @@ -202,6 +279,19 @@ fn spawn_ssh_args(args: &[String]) -> Result { Ok(child) } +/// Spawn `ssh` without the blocking liveness check. Used by auto-reconnect, +/// which runs on the UI thread every loop iteration and must not sleep; a +/// respawn that dies immediately is caught on the next `cleanup()` tick. +fn spawn_ssh_args_nowait(args: &[String]) -> Result { + Command::new("ssh") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to start ssh: {e}")) +} + /// Manages multiple SSH tunnels. pub struct ForwardManager { pub tunnels: Vec, @@ -259,8 +349,11 @@ impl ForwardManager { /// 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`. + /// deferred by `INITIAL_BACKOFF`; the backoff doubles on each attempt up to + /// `MAX_BACKOFF` and is only reset once the tunnel stays `Alive` for + /// `STABILITY_THRESHOLD` (see `refresh_status`). After + /// `MAX_RECONNECT_ATTEMPTS` consecutive failures the tunnel gives up + /// (`auto_reconnect = false`) so an unreachable host isn't retried forever. pub fn reconnect_failed(&mut self) -> usize { let now = Instant::now(); let mut reconnected = 0; @@ -273,24 +366,41 @@ 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: try to respawn (non-blocking — never sleeps the UI). + Some(_) => { + tunnel.retry_count += 1; + let outcome = tunnel.respawn(false); + // Grow the backoff for the *next* attempt regardless of + // whether this spawn launched; only a sustained `Alive` + // period resets it. + tunnel.retry_backoff = (tunnel.retry_backoff * 2).min(MAX_BACKOFF); + match outcome { + Ok(()) => { + reconnected += 1; + // Reschedule from the next observed failure. + tunnel.next_retry_at = None; + } + Err(_) => { + tunnel.next_retry_at = Some(now + tunnel.retry_backoff); + } + } + if tunnel.retry_count >= MAX_RECONNECT_ATTEMPTS { + 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 have permanently failed — i.e. `Failed` *and* no + /// longer auto-reconnecting (retries exhausted or explicitly disabled). + /// Tunnels still cycling through reconnect attempts are kept, so saving the + /// list (which calls this) never silently deletes a tunnel that just + /// happens to be between retries. pub fn drop_failed(&mut self) { 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 +491,33 @@ 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`. + + #[test] + fn shell_quote_leaves_safe_args_untouched() { + assert_eq!(shell_quote("-L"), "-L"); + assert_eq!(shell_quote("8080:localhost:80"), "8080:localhost:80"); + assert_eq!( + shell_quote("/home/user/.ssh/id_rsa"), + "/home/user/.ssh/id_rsa" + ); + assert_eq!(shell_quote("user@host"), "user@host"); + } + + #[test] + fn shell_quote_wraps_paths_with_spaces() { + assert_eq!( + shell_quote("/Users/x/my keys/id_rsa"), + "'/Users/x/my keys/id_rsa'" + ); + } + + #[test] + fn shell_quote_escapes_embedded_single_quote() { + assert_eq!(shell_quote("a'b"), "'a'\\''b'"); + } + + #[test] + fn shell_quote_quotes_empty_arg() { + assert_eq!(shell_quote(""), "''"); + } } diff --git a/crates/prt/src/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index f53aab4..eb8ecc6 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -3,26 +3,19 @@ use crate::app::App; use crate::forward::TunnelStatus; use crossterm::event::{KeyCode, KeyEvent}; +use prt_core::core::scanner::format_uptime; use prt_core::core::ssh_tunnel::TunnelKind; use prt_core::i18n; -use prt_core::model::ConnectionState; +use prt_core::model::{ConnectionState, TICK_RATE}; 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) - } -} +/// Grace period after (re)start before a missing listener is reported. The scan +/// backing `has_local_listener` only refreshes every `TICK_RATE`, and a tunnel +/// needs a tick to go `Starting -> Alive` plus another for the scan to observe +/// its `LISTEN` socket, so we'd otherwise flash a bogus "no listener". +const LISTENER_GRACE: Duration = TICK_RATE.saturating_mul(2); /// 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: @@ -91,9 +84,19 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { // 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". + // + // This signal is advisory and view-only: the tunnel stays `Alive`, + // so the auto-reconnect loop never acts on a "no listener" tunnel. + // + // Guard against false positives: the scan refreshes only every + // `TICK_RATE` (and not at all while auto-refresh is paused), so a + // freshly (re)started tunnel hasn't been observed yet. Within the + // grace window — or whenever the scan is frozen — trust the `Alive` + // status instead of crying "no listener". let (status, color) = match t.last_status { TunnelStatus::Alive => { - if has_local_listener(app, t.spec.local_port) { + let scan_can_confirm = !app.auto_refresh_paused && t.uptime() >= LISTENER_GRACE; + if !scan_can_confirm || 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) @@ -106,7 +109,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { // Uptime is only meaningful while the child is running. let uptime = match t.last_status { TunnelStatus::Failed => "-".to_string(), - _ => fmt_uptime(t.uptime()), + _ => format_uptime(t.uptime()), }; Row::new(vec![ @@ -194,21 +197,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"); - } -} From 79dd4c1e4b69be722380c0e8d972775c124b350e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:42:30 +0000 Subject: [PATCH 5/6] fix(tui): confirm listener PID belongs to the tunnel's ssh child Address Codex review: OpenSSH defaults to `ExitOnForwardFailure no`, so on a local-port conflict the ssh child keeps running while another process owns the port. The previous `LISTEN + port` match would then report the tunnel green even though the forward never bound. Require the listening socket's PID to be this tunnel's ssh child so a masked bind failure correctly shows `no listener`. https://claude.ai/code/session_01V2L7zMrNT4vcJFjAHyuShp --- crates/prt/src/forward.rs | 7 +++++++ crates/prt/src/views/tunnels.rs | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index c1eb53b..9b1c0d4 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -194,6 +194,13 @@ impl SshTunnel { self.started_at.elapsed() } + /// PID of the current `ssh` child. For `-L`/`-D` tunnels this is the + /// process that binds the local port, so the listener health check can + /// confirm a `LISTEN` socket really belongs to *this* tunnel. + pub fn pid(&self) -> u32 { + self.child.id() + } + /// The full `ssh` command line this tunnel was spawned with — handy for /// copying to the clipboard and reproducing the tunnel outside prt. Each /// argument is shell-quoted so paths with spaces (e.g. an identity file diff --git a/crates/prt/src/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index eb8ecc6..cd24a10 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -17,12 +17,20 @@ use std::time::Duration; /// its `LISTEN` socket, so we'd otherwise flash a bogus "no listener". const LISTENER_GRACE: Duration = TICK_RATE.saturating_mul(2); -/// 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: +/// True if `ssh_pid` owns a `LISTEN` socket on `local_port` in the latest scan +/// — confirms an `Alive` tunnel actually opened its own socket. Read-only: /// reuses the data prt already scanned, opens no new connections. -fn has_local_listener(app: &App, local_port: u16) -> bool { +/// +/// The PID match matters: OpenSSH defaults to `ExitOnForwardFailure no`, so on +/// a local-port conflict the `ssh` child keeps running while *another* process +/// owns the port. Matching `LISTEN + port` alone would then mask the bind +/// failure as healthy; requiring the listener's PID to be our `ssh` child +/// avoids that false green. +fn has_local_listener(app: &App, local_port: u16, ssh_pid: u32) -> bool { app.session.entries.iter().any(|e| { - e.entry.state == ConnectionState::Listen && e.entry.local_addr.port() == local_port + e.entry.state == ConnectionState::Listen + && e.entry.local_addr.port() == local_port + && e.entry.process.pid == ssh_pid }) } @@ -96,7 +104,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { let (status, color) = match t.last_status { TunnelStatus::Alive => { let scan_can_confirm = !app.auto_refresh_paused && t.uptime() >= LISTENER_GRACE; - if !scan_can_confirm || has_local_listener(app, t.spec.local_port) { + if !scan_can_confirm || has_local_listener(app, t.spec.local_port, t.pid()) { (s.tunnel_status_alive.to_string(), Color::Green) } else { (s.tunnel_health_no_listener.to_string(), Color::Yellow) From 5ced54e60a61dec3009eecabc64906b33193e8f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:47:31 +0000 Subject: [PATCH 6/6] fix(tui): re-arm auto-reconnect when a tunnel proves stably alive Address Codex review: the give-up at MAX_RECONNECT_ATTEMPTS flips auto_reconnect to false even when that attempt's respawn succeeds. If the host actually recovers on the final attempt, refresh_status resets the backoff and retry_count after STABILITY_THRESHOLD but previously left auto_reconnect false, so a recovered tunnel that later dropped was stuck permanently failed. Re-enable auto_reconnect alongside the stability reset so a proven-healthy tunnel is always eligible to reconnect again. https://claude.ai/code/session_01V2L7zMrNT4vcJFjAHyuShp --- crates/prt/src/forward.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index 9b1c0d4..1824ac7 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -136,11 +136,15 @@ impl SshTunnel { // came up (an unreachable host keeps `ssh` alive for a few // seconds before TCP timeout). Reset the backoff here rather // than on respawn so the exponential growth survives a host - // that flaps every few seconds. + // that flaps every few seconds. Also re-arm `auto_reconnect`: + // if the give-up at `MAX_RECONNECT_ATTEMPTS` happened to land + // on an attempt that actually recovered, a now-healthy tunnel + // must be eligible to reconnect again if it later drops. if self.started_at.elapsed() >= STABILITY_THRESHOLD { self.retry_backoff = INITIAL_BACKOFF; self.retry_count = 0; self.next_retry_at = None; + self.auto_reconnect = true; } TunnelStatus::Alive }