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