From 223d7c78eb4c7aacd2058783647d2ea8a799320b Mon Sep 17 00:00:00 2001 From: Mapika Date: Sat, 1 Aug 2026 08:42:47 +0200 Subject: [PATCH] feat(ssh): watch a remote host with nothing installed on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe loops on the far end and the TUI reads the records it emits, so a session costs one SSH connection rather than one per tick. No framing needed inventing: the probe already terminates each record with #END. Killing signals the PID directly, since the installed-portview path shells out to `portview kill` — precisely what is missing here. A remote session that ends now reports why on stderr after the terminal is restored. As a status line it rendered for one frame and then left with the alternate screen. --- CHANGELOG.md | 11 +++ README.md | 17 +++- src/agentless.rs | 39 ++++++++ src/ssh.rs | 95 +++++++++++++++++--- src/tui.rs | 229 ++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 342 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c967e1..1a672f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ `etime`/`time` spellings, which are clock-formatted rather than plain seconds. Without that, every lsof-collected port would have come back with no user, memory, or command at all. +- `portview ssh watch --agentless` watches a host with nothing installed + on it, interactive kill included. The probe loops on the far end and the TUI + reads the records it emits, so a session costs one SSH connection rather than + one per refresh — verified as a single invocation across a whole run. Killing + signals the PID directly, since `portview kill` is exactly what is missing + there. + + With this, agentless mode covers every command. A remote session that ends — + a dropped connection, or a host with no collector on it — now reports why on + stderr after the terminal is restored, rather than as a status line that the + alternate screen takes away with it. ### Fixed diff --git a/README.md b/README.md index c68698a..e4acfaa 100644 --- a/README.md +++ b/README.md @@ -232,9 +232,18 @@ That produces the same findings as running `portview doctor` on the host itself. The Docker check is reported as skipped rather than passed, since the probe doesn't query Docker on the far end. -Agentless mode covers scans, port inspection, process search, and diagnostics. -`watch` still needs portview installed remotely — the TUI consumes a streaming -JSON pipe. +`watch` works agentless as well, including the interactive kill: + +```bash +portview ssh user@server watch --agentless +``` + +The probe loops on the far end and the TUI reads the records it sends back, so +the whole session costs one SSH connection rather than one per refresh. + +Agentless mode covers everything: scans, port inspection, process search, +diagnostics, and watch. On Linux it uses `ss` and `ps`; where `ss` does not +exist it falls back to `lsof`, which covers macOS and the BSDs. ### Docker integration @@ -349,7 +358,7 @@ cargo check --target x86_64-pc-windows-msvc - **macOS:** Other users' ports are *not* listed without `sudo` — sockets are enumerated per process via `proc_pidfdinfo`, so a process that can't be opened contributes nothing to enumerate. For the same reason doctor cannot detect TIME_WAIT pileups there; CLOSE_WAIT is detected normally. - **Windows:** Ports owned by inaccessible system processes are listed with the PID but `-` for name and user. Kill always force-terminates. Run as Administrator for full detail. - **Docker:** Requires `docker` CLI and daemon access -- **SSH:** `watch` requires portview on the remote host. Scans, inspection, search and `doctor` fall back to agentless collection (`ss` + `ps`), which needs a Linux remote — macOS and BSD hosts still need portview installed. +- **SSH:** every command falls back to agentless collection when portview is missing on the remote, using `ss` + `ps` on Linux and `lsof` where `ss` does not exist. The remote needs one of those and a POSIX shell. Agentless collection cannot see Docker on the far end, so that check reports as skipped rather than passed. ## License diff --git a/src/agentless.rs b/src/agentless.rs index 92ed7b9..e15c7a6 100644 --- a/src/agentless.rs +++ b/src/agentless.rs @@ -61,6 +61,26 @@ done printf '#%s\n' END "#; +/// Terminates one probe record. `watch` reads a stream of these. +pub(crate) const RECORD_END: &str = "#END"; + +/// The probe wrapped in a poll loop, for `watch`. +/// +/// One SSH connection carries the whole session rather than one per tick: an +/// SSH handshake costs far more than the probe itself, and re-authenticating +/// every second would be noticed by anything watching auth logs. +/// +/// No framing has to be invented for this — the probe already terminates each +/// record with `#END`, so the reader on this side just accumulates until it +/// sees one. The `exit 0` inside the probe's no-collector branch ends the loop +/// too, which is what we want: nothing will change by trying again. +pub(crate) fn probe_loop(interval_secs: u64) -> String { + // The marker constraint documented above `PROBE` applies here as well: this + // wrapper is part of the command line `ps` reports, so it must not spell a + // marker literally either. + format!("while :; do{PROBE}sleep {interval_secs}\ndone\n") +} + /// One row from the remote `ps` table. #[derive(Debug, Clone, Default)] struct ProcRow { @@ -843,9 +863,28 @@ MainThread 18 root 39u IPv4 302431 0t0 TCP 127.0.0.1:7000->127.0.0.1:3 "PROBE contains the literal marker {} — print it as '#%s' instead", marker ); + // The watch wrapper is part of the same command line, so it carries + // the same constraint. + assert!( + !probe_loop(1).contains(marker), + "probe_loop contains the literal marker {}", + marker + ); } } + #[test] + fn probe_loop_repeats_the_probe_and_sleeps() { + let script = probe_loop(3); + assert!(script.starts_with("while :; do")); + assert!(script.contains("sleep 3")); + assert!(script.trim_end().ends_with("done")); + // The probe itself must survive intact; a mangled copy would still run + // and simply return nothing. + assert!(script.contains("ss -tanp")); + assert!(script.contains("lsof -nP -i")); + } + #[test] fn the_probes_own_command_line_does_not_corrupt_parsing() { // Regression: the shell running the probe appears in the process table diff --git a/src/ssh.rs b/src/ssh.rs index 4699197..aed6a06 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -78,6 +78,45 @@ impl SshCommand { let text = String::from_utf8_lossy(&output.stdout); crate::agentless::parse_probe(&text) } + + /// Start a long-lived agentless collection stream for `watch`. + /// + /// The probe loops on the far end and this side reads the records it emits, + /// so the session costs one SSH handshake rather than one per tick. + pub fn spawn_agentless_stream(&self, interval_secs: u64) -> Result { + let mut cmd = self.build_shell(&crate::agentless::probe_loop(interval_secs)); + cmd.stdout(process::Stdio::piped()); + cmd.stderr(process::Stdio::piped()); + cmd.spawn() + .map_err(|e| format!("Failed to start SSH: {}", e)) + } + + /// Terminate a remote process without portview on the far end. + /// + /// The installed-portview path shells out to `portview kill`, which is + /// exactly what is missing here, so this sends a signal directly. The PID is + /// a `u32` from the probe's own output, so it cannot carry shell syntax. + pub fn kill_remote_pid(&self, pid: u32, force: bool) -> Result<(), String> { + let signal = if force { "KILL" } else { "TERM" }; + let output = self + .build_shell(&format!("kill -{} {}", signal, pid)) + .output() + .map_err(|e| format!("Failed to run ssh: {}", e))?; + + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.lines().next().unwrap_or("").trim(); + if detail.is_empty() { + Err(format!( + "kill -{} {} failed on the remote host", + signal, pid + )) + } else { + Err(detail.to_string()) + } + } } /// Does this SSH failure mean portview simply is not installed on the far end? @@ -143,15 +182,10 @@ pub(crate) fn run_ssh( let first_arg = remote_args.first().map(|s| s.as_str()); - // watch still needs portview on the far end: the TUI consumes a streaming - // JSON pipe. doctor does not — its checks are pure functions over collected - // data, so they run locally against what the probe brought back. if agentless && first_arg == Some("watch") { - eprintln!( - "--agentless does not support `watch`; it needs portview installed on {}.", - destination - ); - std::process::exit(1); + let show_all = remote_args.iter().any(|a| a == "--all" || a == "-a"); + run_agentless_tui(&ssh, use_color, show_all); + return; } if agentless && first_arg == Some("doctor") { @@ -247,11 +281,46 @@ fn run_ssh_tui(ssh: &SshCommand, remote_args: &[&str], use_color: bool) { } }; - let no_color = !use_color; - if let Err(e) = - crate::tui::run_remote_tui(&ssh.destination, ssh.ssh_opts.clone(), child, no_color) - { - eprintln!("TUI error: {}", e); + start_remote_tui(ssh, child, use_color, crate::tui::RemoteFeed::Json); +} + +/// Watch a host with nothing installed on it. +/// +/// The probe loops on the far end over a single connection, so this is one SSH +/// session for the whole run rather than one per tick. +fn run_agentless_tui(ssh: &SshCommand, use_color: bool, show_all: bool) { + let child = match ssh.spawn_agentless_stream(1) { + Ok(c) => c, + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + }; + + start_remote_tui( + ssh, + child, + use_color, + crate::tui::RemoteFeed::Probe { show_all }, + ); +} + +fn start_remote_tui( + ssh: &SshCommand, + child: process::Child, + use_color: bool, + feed: crate::tui::RemoteFeed, +) { + // Reported without a prefix: this carries the reason a remote session + // ended, which is a sentence meant for the user, not an internal error. + if let Err(e) = crate::tui::run_remote_tui( + &ssh.destination, + ssh.ssh_opts.clone(), + child, + !use_color, + feed, + ) { + eprintln!("{}", e); std::process::exit(1); } } diff --git a/src/tui.rs b/src/tui.rs index e4cab71..77a4c3d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -250,6 +250,9 @@ pub struct App { tree_mode: bool, remote_host: Option, ssh_opts: Vec, + /// Remote session with no portview on the far end, so actions that would + /// shell out to `portview` there have to be expressed some other way. + agentless: bool, } impl App { @@ -292,6 +295,7 @@ impl App { tree_mode: false, remote_host: None, ssh_opts: Vec::new(), + agentless: false, }; app.refresh_data(); if !app.display_ports().is_empty() { @@ -1362,14 +1366,21 @@ fn handle_kill_popup_key(app: &mut App, code: KeyCode) { destination: host.clone(), ssh_opts: app.ssh_opts.clone(), }; - let port_str = popup.port.to_string(); - let args: Vec<&str> = if popup.force { - vec!["kill", &port_str, "--force"] + let result = if app.agentless { + // `portview kill` is precisely what is not installed on + // the far end, so signal the PID the probe reported. + ssh.kill_remote_pid(popup.pid, popup.force) } else { - vec!["kill", &port_str] + let port_str = popup.port.to_string(); + let args: Vec<&str> = if popup.force { + vec!["kill", &port_str, "--force"] + } else { + vec!["kill", &port_str] + }; + ssh.run_oneshot(&args).map(|_| ()) }; - match ssh.run_oneshot(&args) { - Ok(_) => { + match result { + Ok(()) => { app.status_message = Some(( format!("Killed remote port {}", popup.port), Instant::now(), @@ -1511,14 +1522,76 @@ pub fn run_tui( // ── Remote TUI (SSH pipe mode) ─────────────────────────────────────── +/// How a remote session delivers snapshots. +/// +/// The two differ in framing, not in content: a remote portview emits one JSON +/// array per line, while the agentless probe emits a multi-line record ending +/// in `#END`. Reading is the only part of the TUI that needs to know which. +pub enum RemoteFeed { + /// `portview watch --json` on the far end. + Json, + /// The agentless probe looping over one connection. + Probe { show_all: bool }, +} + +/// Read the next snapshot from a remote feed. +/// +/// `Ok(None)` is a clean end of stream. `Err` is a remote-side problem worth +/// showing the user, such as a host with no collector on it at all. +fn read_remote_snapshot( + reader: &mut impl std::io::BufRead, + feed: &RemoteFeed, + buf: &mut String, +) -> Result>, String> { + match feed { + RemoteFeed::Json => { + buf.clear(); + match reader.read_line(buf) { + Ok(0) => Ok(None), + Ok(_) => Ok(crate::ssh::parse_port_json(buf.trim()).ok()), + Err(e) => Err(e.to_string()), + } + } + RemoteFeed::Probe { show_all } => { + let mut record = String::new(); + loop { + buf.clear(); + match reader.read_line(buf) { + // The probe exits without a closing marker when the host has + // no collector, so a partial record here is that message + // rather than an ordinary disconnect. + Ok(0) => { + return match crate::agentless::parse_probe(&record) { + Err(e) if !record.trim().is_empty() => Err(e), + _ => Ok(None), + }; + } + Ok(_) => { + record.push_str(buf); + if buf.trim_end() == crate::agentless::RECORD_END { + let ports = crate::agentless::parse_probe(&record)?; + let ports = if *show_all { + ports + } else { + crate::agentless::filter_listening(ports) + }; + return Ok(Some(ports)); + } + } + Err(e) => return Err(e.to_string()), + } + } + } + } +} + pub fn run_remote_tui( host: &str, ssh_opts: Vec, mut child: std::process::Child, no_color: bool, + feed: RemoteFeed, ) -> io::Result<()> { - use std::io::BufRead; - enable_raw_mode()?; let mut stdout = io::stdout(); stdout.execute(EnterAlternateScreen)?; @@ -1538,13 +1611,15 @@ pub fn run_remote_tui( StyleConfig::btop_default() }; + let agentless = matches!(feed, RemoteFeed::Probe { .. }); let mut app = App { ports: Vec::new(), docker_enabled: false, docker_map: DockerPortMap::default(), table_state: TableState::default(), mode: AppMode::Table, - show_all: false, + // The producer decides what is in `ports`; this only labels the view. + show_all: matches!(feed, RemoteFeed::Probe { show_all: true }), filter_text: String::new(), popup: None, target: None, @@ -1561,6 +1636,7 @@ pub fn run_remote_tui( tree_mode: false, remote_host: Some(host.to_string()), ssh_opts, + agentless, }; let child_stdout = child.stdout.take().expect("piped stdout"); @@ -1568,6 +1644,7 @@ pub fn run_remote_tui( let mut line_buf = String::new(); let tick_rate = Duration::from_millis(100); + let mut fatal: Option = None; loop { terminal.draw(|frame| render(frame, &mut app))?; @@ -1578,32 +1655,32 @@ pub fn run_remote_tui( // Try to read data from SSH pipe when due if app.last_refresh.elapsed() >= Duration::from_secs(1) { - line_buf.clear(); - match reader.read_line(&mut line_buf) { - Ok(0) => { - // EOF - app.status_message = Some(("Connection lost".to_string(), Instant::now())); - app.should_quit = true; - continue; - } - Ok(_) => { - if let Ok(ports) = crate::ssh::parse_port_json(line_buf.trim()) { - app.ports = ports; - app.last_refresh = Instant::now(); - let count = app.display_ports().len(); - if count == 0 { - app.table_state.select(None); - } else if let Some(sel) = app.table_state.selected() { - if sel >= count { - app.table_state.select(Some(count - 1)); - } - } else { - app.table_state.select(Some(0)); + match read_remote_snapshot(&mut reader, &feed, &mut line_buf) { + Ok(Some(ports)) => { + app.ports = ports; + app.last_refresh = Instant::now(); + let count = app.display_ports().len(); + if count == 0 { + app.table_state.select(None); + } else if let Some(sel) = app.table_state.selected() { + if sel >= count { + app.table_state.select(Some(count - 1)); } + } else { + app.table_state.select(Some(0)); } } - Err(_) => { - app.status_message = Some(("Read error".to_string(), Instant::now())); + // A stream that ends is fatal, and the TUI is about to be torn + // down — a status message would render for a single frame and + // vanish with the alternate screen, so it is reported on stderr + // after the terminal is restored instead. + Ok(None) => { + fatal = Some(format!("Connection to {} lost.", host)); + app.should_quit = true; + continue; + } + Err(e) => { + fatal = Some(e); app.should_quit = true; continue; } @@ -1627,6 +1704,10 @@ pub fn run_remote_tui( terminal.backend_mut().execute(LeaveAlternateScreen)?; terminal.show_cursor()?; + if let Some(message) = fatal { + return Err(io::Error::other(message)); + } + Ok(()) } @@ -1680,9 +1761,93 @@ mod tests { tree_mode: false, remote_host: None, ssh_opts: Vec::new(), + agentless: false, } } + // ── Remote feed framing ────────────────────────────────────────── + + const PROBE_RECORD: &str = "\ +#TCP +LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:((\"node\",pid=6,fd=21)) +ESTAB 0 0 127.0.0.1:3000 127.0.0.1:51234 users:((\"node\",pid=6,fd=24)) +#UDP +#PROC + 6 1 root 45552 120 4 node /opt/app/web.js +#EXE +6\t/usr/bin/node +#END +"; + + #[test] + fn probe_feed_reads_one_record_per_end_marker() { + // Two records back to back: the reader must stop at the first `#END` + // rather than swallowing the stream. + let stream = format!("{PROBE_RECORD}{PROBE_RECORD}"); + let mut reader = std::io::Cursor::new(stream.into_bytes()); + let feed = RemoteFeed::Probe { show_all: false }; + let mut buf = String::new(); + + for _ in 0..2 { + let ports = read_remote_snapshot(&mut reader, &feed, &mut buf) + .expect("record parses") + .expect("record present"); + assert_eq!(ports.len(), 1, "{:#?}", ports); + assert_eq!(ports[0].port, 3000); + } + + // Third read hits the end of the stream cleanly. + assert!( + read_remote_snapshot(&mut reader, &feed, &mut buf) + .expect("clean end of stream") + .is_none() + ); + } + + #[test] + fn probe_feed_honours_show_all() { + let mut reader = std::io::Cursor::new(PROBE_RECORD.as_bytes().to_vec()); + let mut buf = String::new(); + let ports = + read_remote_snapshot(&mut reader, &RemoteFeed::Probe { show_all: true }, &mut buf) + .unwrap() + .unwrap(); + // The listener and the established connection, where the default view + // keeps only the listener. + assert_eq!(ports.len(), 2, "{:#?}", ports); + } + + #[test] + fn probe_feed_surfaces_a_host_with_no_collector() { + // The probe exits without a closing marker in this case, so a partial + // record at end of stream is a message rather than a disconnect. + let mut reader = std::io::Cursor::new(b"#NOSS\n".to_vec()); + let mut buf = String::new(); + let err = read_remote_snapshot( + &mut reader, + &RemoteFeed::Probe { show_all: false }, + &mut buf, + ) + .expect_err("must report why the stream ended"); + assert!(err.contains("lsof"), "unhelpful message: {err}"); + } + + #[test] + fn json_feed_reads_one_array_per_line() { + let line = b"[{\"port\":3000,\"protocol\":\"TCP\",\"pid\":6,\"process\":\"node\",\"command\":\"node\",\"user\":\"root\",\"state\":\"LISTEN\",\"memory_bytes\":1,\"cpu_seconds\":0.0,\"children\":0}]\n"; + let mut reader = std::io::Cursor::new(line.to_vec()); + let mut buf = String::new(); + let ports = read_remote_snapshot(&mut reader, &RemoteFeed::Json, &mut buf) + .unwrap() + .unwrap(); + assert_eq!(ports[0].port, 3000); + assert!( + read_remote_snapshot(&mut reader, &RemoteFeed::Json, &mut buf) + .unwrap() + .is_none() + ); + } + #[test] fn filtered_ports_no_filter() { let mut app = make_test_app(vec![