Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host> 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

Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions src/agentless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
95 changes: 82 additions & 13 deletions src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<process::Child, String> {
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?
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading