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 60a3139..1ed2c82 100644 --- a/crates/prt-core/src/core/suspicious.rs +++ b/crates/prt-core/src/core/suspicious.rs @@ -9,6 +9,7 @@ //! 1. **NonRootPrivileged** — non-root process listening on port < 1024 //! 2. **ScriptOnSensitive** — scripting language on a sensitive port (22, 80, 443) //! 3. **RootHighPortOutgoing** — root process with outgoing connection to high port +//! 4. **ProxyListening** — process listening on a well-known SOCKS/proxy port use crate::model::{ConnectionState, PortEntry, SuspiciousReason}; @@ -18,6 +19,22 @@ const SCRIPT_NAMES: &[&str] = &["python", "python3", "perl", "ruby", "node"]; /// Sensitive ports that should not normally be served by scripting languages. const SENSITIVE_PORTS: &[u16] = &[22, 80, 443]; +/// Well-known SOCKS/proxy listener ports. Deliberately narrow — `8080`/`8443` +/// are excluded because they are far more often legitimate HTTP(S)-alt servers +/// than proxies, which would make this heuristic too noisy. +/// +/// 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) + 3128, // Squid + 9050, // Tor SOCKS + 9150, // Tor Browser SOCKS +]; + /// Run all heuristics on a single port entry and return matching reasons. pub fn check(entry: &PortEntry) -> Vec { let mut reasons = Vec::new(); @@ -31,6 +48,9 @@ pub fn check(entry: &PortEntry) -> Vec { if is_root_high_port_outgoing(entry) { reasons.push(SuspiciousReason::RootHighPortOutgoing); } + if is_proxy_listening(entry) { + reasons.push(SuspiciousReason::ProxyListening); + } reasons } @@ -83,6 +103,15 @@ fn is_root_high_port_outgoing(entry: &PortEntry) -> bool { } } +/// Process listening on a well-known SOCKS/proxy port (see [`PROXY_PORTS`]). +/// +/// A SOCKS/proxy listener is worth surfacing: it may be a legitimate `ssh -D` +/// tunnel, but it can equally be an attacker pivoting traffic through the host. +/// Flagging it lets the operator confirm the listener is one they started. +fn is_proxy_listening(entry: &PortEntry) -> bool { + entry.state == ConnectionState::Listen && PROXY_PORTS.contains(&entry.local_addr.port()) +} + #[cfg(test)] mod tests { use super::*; @@ -293,4 +322,34 @@ mod tests { let entry = listen_entry(443, "nginx", Some("root")); assert!(check(&entry).is_empty()); } + + // ── ProxyListening ─────────────────────────────────────────── + + #[test] + fn socks_listener_flagged() { + let entry = listen_entry(1080, "ssh", Some("user")); + assert!(is_proxy_listening(&entry)); + assert!(check(&entry).contains(&SuspiciousReason::ProxyListening)); + } + + #[test] + fn tor_socks_listener_flagged() { + let entry = listen_entry(9050, "tor", Some("debian-tor")); + assert!(check(&entry).contains(&SuspiciousReason::ProxyListening)); + } + + #[test] + fn http_alt_8080_not_flagged_as_proxy() { + // 8080 is excluded to avoid false positives on legitimate http-alt. + let entry = listen_entry(8080, "nginx", Some("www-data")); + assert!(!is_proxy_listening(&entry)); + } + + #[test] + fn proxy_port_established_not_flagged() { + // An outgoing connection to a proxy port is not a local proxy listener. + let mut entry = listen_entry(1080, "curl", Some("user")); + entry.state = ConnectionState::Established; + assert!(!is_proxy_listening(&entry)); + } } diff --git a/crates/prt-core/src/i18n/en.rs b/crates/prt-core/src/i18n/en.rs index 7d6b44e..c393a48 100644 --- a/crates/prt-core/src/i18n/en.rs +++ b/crates/prt-core/src/i18n/en.rs @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "Remote", tunnel_col_host: "Host", tunnel_col_status: "Status", + tunnel_col_uptime: "Uptime", tunnel_status_alive: "alive", tunnel_status_dead: "dead", tunnel_status_starting: "starting", tunnel_status_failed: "failed", + tunnel_health_no_listener: "no listener", tunnel_form_edit_title: " Edit SSH Tunnel ", tunnel_form_field_required: "required", tunnels_empty: " No active tunnels. Press [n] to create one.", @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "new", hint_kill_tunnel: "kill", hint_restart_tunnel: "restart", + hint_copy_tunnel: "copy cmd", hint_save_tunnels: "save", hint_reload: "reload", hint_open_tunnel: "tunnel", diff --git a/crates/prt-core/src/i18n/mod.rs b/crates/prt-core/src/i18n/mod.rs index 1892c76..0654701 100644 --- a/crates/prt-core/src/i18n/mod.rs +++ b/crates/prt-core/src/i18n/mod.rs @@ -249,10 +249,12 @@ pub struct Strings { pub tunnel_col_remote: &'static str, pub tunnel_col_host: &'static str, pub tunnel_col_status: &'static str, + pub tunnel_col_uptime: &'static str, pub tunnel_status_alive: &'static str, pub tunnel_status_dead: &'static str, pub tunnel_status_starting: &'static str, pub tunnel_status_failed: &'static str, + pub tunnel_health_no_listener: &'static str, pub tunnel_form_edit_title: &'static str, pub tunnel_form_field_required: &'static str, pub tunnels_empty: &'static str, @@ -277,6 +279,7 @@ pub struct Strings { pub hint_new_tunnel: &'static str, pub hint_kill_tunnel: &'static str, pub hint_restart_tunnel: &'static str, + pub hint_copy_tunnel: &'static str, pub hint_save_tunnels: &'static str, pub hint_reload: &'static str, pub hint_open_tunnel: &'static str, diff --git a/crates/prt-core/src/i18n/ru.rs b/crates/prt-core/src/i18n/ru.rs index ee42127..efd2c38 100644 --- a/crates/prt-core/src/i18n/ru.rs +++ b/crates/prt-core/src/i18n/ru.rs @@ -143,10 +143,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "Удалённ.", tunnel_col_host: "Хост", tunnel_col_status: "Статус", + tunnel_col_uptime: "Аптайм", tunnel_status_alive: "активен", tunnel_status_dead: "мёртв", tunnel_status_starting: "запускается", tunnel_status_failed: "сбой", + tunnel_health_no_listener: "нет листенера", tunnel_form_edit_title: " Правка SSH-туннеля ", tunnel_form_field_required: "обязательно", tunnels_empty: " Активных туннелей нет. Нажмите [n] чтобы создать.", @@ -169,6 +171,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "новый", hint_kill_tunnel: "убить", hint_restart_tunnel: "рестарт", + hint_copy_tunnel: "копир. cmd", hint_save_tunnels: "сохр.", hint_reload: "обновить", hint_open_tunnel: "туннель", diff --git a/crates/prt-core/src/i18n/zh.rs b/crates/prt-core/src/i18n/zh.rs index fcd0599..1f1d3e3 100644 --- a/crates/prt-core/src/i18n/zh.rs +++ b/crates/prt-core/src/i18n/zh.rs @@ -142,10 +142,12 @@ pub static STRINGS: Strings = Strings { tunnel_col_remote: "远端", tunnel_col_host: "主机", tunnel_col_status: "状态", + tunnel_col_uptime: "运行时长", tunnel_status_alive: "活跃", tunnel_status_dead: "已断", tunnel_status_starting: "启动中", tunnel_status_failed: "失败", + tunnel_health_no_listener: "无监听", tunnel_form_edit_title: " 编辑 SSH 隧道 ", tunnel_form_field_required: "必填", tunnels_empty: " 无活跃隧道。按 [n] 创建。", @@ -168,6 +170,7 @@ pub static STRINGS: Strings = Strings { hint_new_tunnel: "新建", hint_kill_tunnel: "终止", hint_restart_tunnel: "重启", + hint_copy_tunnel: "复制命令", hint_save_tunnels: "保存", hint_reload: "重载", hint_open_tunnel: "隧道", diff --git a/crates/prt-core/src/known_ports.rs b/crates/prt-core/src/known_ports.rs index 397556a..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; @@ -77,6 +82,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 +166,13 @@ fn builtin_lookup(port: u16) -> Option<&'static str> { 9000 => Some("php-fpm"), 9042 => Some("cassandra"), 9043 => Some("websphere"), + 9050 => Some("tor-socks"), 9090 => Some("prometheus"), 9091 => Some("transmsn"), 9092 => Some("kafka"), 9093 => Some("alertmgr"), 9100 => Some("node-exp"), + 9150 => Some("tor-socks"), 9160 => Some("cassandra"), 9200 => Some("elastic"), 9300 => Some("elastic"), diff --git a/crates/prt-core/src/model.rs b/crates/prt-core/src/model.rs index 63d4201..5eceae9 100644 --- a/crates/prt-core/src/model.rs +++ b/crates/prt-core/src/model.rs @@ -180,6 +180,8 @@ pub enum SuspiciousReason { ScriptOnSensitive, /// Root process making outgoing connection to a high port. RootHighPortOutgoing, + /// Process listening on a well-known SOCKS/proxy port (1080, 9050, …). + ProxyListening, } /// Column by which the port table can be sorted. diff --git a/crates/prt/src/app.rs b/crates/prt/src/app.rs index d3ca93f..07a3250 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) { @@ -559,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 f55eef7..1824ac7 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)] @@ -18,6 +18,24 @@ 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); +/// 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). pub struct SshTunnel { @@ -25,21 +43,46 @@ pub struct SshTunnel { args: Vec, child: Child, pub last_status: TunnelStatus, + /// When the current `ssh` child was spawned — reset on `restart()`. + started_at: Instant, + /// Whether to auto-restart this tunnel after it fails on its own. + /// Manual `kill_at` removes the tunnel entirely, so it never reconnects. + /// 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 { + /// 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, + last_status: TunnelStatus::Starting, + 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 { - spec, - args, - child, - last_status: TunnelStatus::Starting, - }) + Ok(Self::from_child(spec, args, child)) } /// Spawn an `ssh` process for `spec`. For aliases defined only in prt's @@ -57,12 +100,7 @@ impl SshTunnel { _ => spec.ssh_args(), }; let child = spawn_ssh_args(&args)?; - Ok(Self { - spec, - args, - child, - last_status: TunnelStatus::Starting, - }) + Ok(Self::from_child(spec, args, child)) } /// Backwards-compat shortcut: spawn a Local tunnel matching the legacy @@ -93,6 +131,24 @@ 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. 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 + } + // `Failed` with a still-running child shouldn't happen. other => other, }, Ok(Some(_)) => TunnelStatus::Failed, @@ -108,13 +164,87 @@ 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(()) } + + /// How long the current `ssh` child has been running. + pub fn uptime(&self) -> Duration { + 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 + /// 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(&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 { @@ -160,6 +290,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, @@ -211,11 +354,64 @@ impl ForwardManager { } } - /// Drop tunnels that have already failed. Called when the user asks to - /// prune the list (e.g. via "save" which only persists running tunnels). + /// Auto-restart tunnels 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`; 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; + 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 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 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. @@ -306,4 +502,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/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..cd24a10 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -3,10 +3,36 @@ 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, TICK_RATE}; use ratatui::prelude::*; use ratatui::widgets::*; +use std::time::Duration; + +/// 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 `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. +/// +/// 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.process.pid == ssh_pid + }) +} pub fn draw(f: &mut Frame, app: &App, area: Rect) { let s = i18n::strings(); @@ -32,6 +58,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 +88,36 @@ 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". + // + // 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 => (s.tunnel_status_alive, Color::Green), - TunnelStatus::Starting => (s.tunnel_status_starting, Color::Yellow), - TunnelStatus::Failed => (s.tunnel_status_failed, Color::Red), + 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, t.pid()) { + (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(), + _ => format_uptime(t.uptime()), }; Row::new(vec![ @@ -73,6 +126,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 +139,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 +194,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