SIGN IN SIGN UP

feat(view): agentless SSH transport (#204)

* feat(view): agentless SSH transport (#194)

Add an SSH-based transport to `all-smi view` so operators can monitor
remote hosts without first installing `all-smi api`. Targets are
probed once on first connect: native `all-smi snapshot --format json`
is preferred, with CSV-parsing `nvidia-smi` and JSON-parsing
`rocm-smi` shims as fallbacks. Unsupported hosts remain tab-visible
with a status chip so operators see what changed.

New CLI surface on `view`:
- --ssh user@host[:port][,...]
- --ssh-hostfile <path> (one target per line, `#` comments allowed)
- --ssh-key / --ssh-config / --ssh-known-hosts
- --ssh-strict-host-key yes|accept-new|no
- --ssh-timeout-secs (default 10)
- --ssh-fallback nvidia-smi,rocm-smi,none (default: both)
- --ssh-concurrency N (default 32, semaphore-limited)

Library:
- russh 0.60 (pure Rust, musl-friendly) for the SSH client
- New modules: ssh_client / ssh_host_key / ssh_target / ssh_decision /
  ssh_transport / nvidia_smi_shim / rocm_smi_shim under src/network/
- New SshStrategy under src/view/data_collection/ parallel to the
  existing Local/Remote strategies
- ConnectionStatus gains transport_chip + connection_state fields so
  the TUI can render per-host status chips (`native`, `nvidia-smi`,
  `rocm-smi`, `unsupported`; `connecting`, `connected`, `auth-failed`,
  `timeout`, `host-key-rejected`, `disconnected`)

Security:
- Password auth is never attempted; key / agent auth only. No
  passwords flow through logs or the CLI.
- `--ssh-strict-host-key=no` emits a prominent warning log.
- `accept-new` persists new keys to the known_hosts file, rejects
  subsequent mismatches.

Tests:
- Unit: hostfile parsing (comments, port suffixes, IPv6 brackets),
  nvidia-smi CSV golden file, rocm-smi JSON golden file, transport
  selection decision tree, strict-host-key policy parsing,
  ConnectionStatus chip classification.
- Integration: tests/ssh_transport_integration.rs covers the
  parser -> decision tree -> connection-status pipeline.

Config file:
- `[view]` section learns ssh / ssh_hostfile / ssh_key / ssh_config /
  ssh_strict_host_key / ssh_timeout_secs / ssh_fallback /
  ssh_known_hosts / ssh_concurrency keys with the documented CLI>env>
  file>default precedence chain (issue #192).

Closes #194

* fix(view): clear dead SSH session on exec failure, safe truncate on UTF-8

The SSH strategy was caching the russh session across collection ticks for
reuse, but never cleared the cache on exec-level transport failures.  When
the remote sshd dropped the TCP connection between ticks, subsequent ticks
would keep reusing the dead session and permanently report the host as
failed.  Now exec errors (I/O, protocol, timeout) invalidate the cached
session so the next tick re-opens a fresh connection.  Non-zero exit codes
are a local issue (missing binary, wrong sudo) and do NOT invalidate the
session.

Also fixed a latent panic in the stderr truncation helper: a remote
command that emitted multi-byte UTF-8 into stderr could land a byte-
index cut in the middle of a codepoint and panic the view loop.  The
fix walks char_indices so the cut always falls on a valid boundary.

* fix(ssh): harden known_hosts write, tighten CSV parser, concurrent exec

C1 (CRITICAL) — known_hosts append no longer follows symlinks. Refuse
the write when the path is already a symlink, and pass O_NOFOLLOW on
Unix so the kernel itself closes the TOCTOU window between the metadata
check and open(). Create with mode 0o600, matching OpenSSH.

H1 (HIGH) — SshSession drops the Mutex wrapping russh's client::Handle.
The handle is already internally Arc-shared and safe to call
concurrently; the external lock was serialising channel_open_session()
calls and defeating russh's channel multiplexing. Concurrent probes on
the same host now multiplex as intended.

H3 (HIGH) — avoid copying stdout/stderr unnecessarily. When the bytes
are already valid UTF-8 (the common case for nvidia-smi / rocm-smi
output), String::from_utf8 consumes the Vec in place. Only pre-invalid
bytes trigger the lossy path.

H4 (HIGH) — nvidia-smi CSV parser requires EXACTLY 10 columns, not
>= 7. A truncated or augmented row now surfaces a typed error rather
than silently misaligning fields (e.g. aliasing temperature values onto
power.draw).

M4 (MEDIUM) — accept-new keeps a per-process in-memory cache of
(host, port, fingerprint). If persisting to known_hosts fails (read-only
fs, full disk, symlink refusal), the cache still detects a key change on
the next connection in the same process. Persistence failures are logged
at error level.

M5 (MEDIUM) — known_hosts_lookup parses comma-separated multi-host
entries (host1,host2 alg key) and skips hashed-hostname lines (|1|…)
rather than misreading them.

M6 (MEDIUM) — hostname parsing validates charset: only ASCII
alphanumerics plus -, ., :, _ are accepted. Keeps shell-metacharacter-
looking inputs (user@;whoami, user@\$(cat /etc/passwd)) out of logs and
tab labels. Bracketed IPv6 body goes through the same check.

M8 (MEDIUM) — every bare matches!() call in the test suite is now
wrapped in assert!(...). Previous forms compiled to a no-op and did
not actually fail on mis-matched error variants.

Regression tests cover each fix:
* symlink-planted known_hosts refuses the append; decoy file unchanged.
* 0o600 mode check on fresh known_hosts files.
* accept-new with a read-only parent dir still rejects a key change.
* multi-host entries match any token; hashed entries are skipped.
* 7-, 9-, 11-column CSV rows surface ColumnCount errors.
* shell-metacharacter hostnames and invalid IPv6 bodies are rejected.

* docs: add known_hosts symlink-refusal note to SSH security section

The README security notes for --ssh mode were missing coverage of the
known_hosts write hardening: O_NOFOLLOW refusal of a pre-planted symlink
and the in-process memory-cache fallback when persistence fails.
J
Jeongkyu Shin committed
4cf56e1d7e10bce7cccbd1d44647b9b4e4f459e6
Parent: 845d24a
Committed by GitHub <noreply@github.com> on 4/20/2026, 9:49:03 PM